plugin (forge): add bridge methods to handle clone counters:
A counter of clones (i.e.; Git clones as Git is currently the only backend) can be incremented or retrieved. Counters are stored per day.
This commit is contained in:
parent
a405d6c8f7
commit
bc95840d46
1 changed files with 191 additions and 2 deletions
|
|
@ -16,9 +16,12 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Iterator
|
||||
from datetime import date as dt_date
|
||||
from typing import Iterator, cast
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
|
||||
from sqlalchemy import literal_column, select
|
||||
from sqlalchemy.dialects.sqlite import insert
|
||||
from twisted.internet import defer
|
||||
|
||||
from libervia.backend import G
|
||||
|
|
@ -27,8 +30,10 @@ from libervia.backend.core.constants import Const as C
|
|||
from libervia.backend.core.core_types import SatXMPPEntity
|
||||
from libervia.backend.core.i18n import _
|
||||
from libervia.backend.core.log import getLogger
|
||||
from libervia.backend.memory.sqla_mapping import PrivateInd
|
||||
from libervia.backend.models.types import JIDType
|
||||
from libervia.backend.tools.config import ConfigOptions, DirectoryPath, PluginSettings
|
||||
from libervia.backend.tools.utils import aio
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
|
@ -77,6 +82,60 @@ class DefaultRepository(BaseModel):
|
|||
repository: str
|
||||
|
||||
|
||||
class CloneCounterRequest(BaseModel):
|
||||
"""Counter statitics request.
|
||||
|
||||
Counters are stored daily. Query can be filtered on specific date or date range. Don't
|
||||
specify any date to get all counters of the repository.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
date: dt_date | None = Field(
|
||||
default=None,
|
||||
description="Specific date to retrieve (YYYY-MM-DD format)",
|
||||
)
|
||||
start_date: dt_date | None = Field(
|
||||
default=None,
|
||||
description="Start date for range query (inclusive, YYYY-MM-DD format)",
|
||||
)
|
||||
end_date: dt_date | None = Field(
|
||||
default=None,
|
||||
description="End date for range query (inclusive, YYYY-MM-DD format)",
|
||||
)
|
||||
|
||||
@field_validator("date")
|
||||
@classmethod
|
||||
def validate_date_exclusive(cls, v: dt_date | None, info):
|
||||
"""date cannot be used together with start_date/end_date."""
|
||||
if v is not None:
|
||||
data = info.data
|
||||
if data.get("start_date") is not None or data.get("end_date") is not None:
|
||||
raise ValueError("date cannot be used together with start_date/end_date")
|
||||
return v
|
||||
|
||||
|
||||
class CloneCounter(BaseModel):
|
||||
"""Clone counter statistics for a repository on a specific date."""
|
||||
|
||||
repository: str = Field(description="Repository name")
|
||||
date: str = Field(description="Date in YYYY-MM-DD format")
|
||||
count: int = Field(description="Number of clones")
|
||||
|
||||
|
||||
class CloneCounters(RootModel):
|
||||
root: list[CloneCounter]
|
||||
|
||||
def __iter__(self) -> Iterator[CloneCounter]: # type: ignore
|
||||
return iter(self.root)
|
||||
|
||||
def append(self, item: CloneCounter) -> None:
|
||||
self.root.append(item)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.root)
|
||||
|
||||
|
||||
class PluginForgeSettings(PluginSettings, plugin_info=PLUGIN_INFO):
|
||||
repositories: ProfileRepositories = Field(
|
||||
default_factory=lambda: ProfileRepositories({})
|
||||
|
|
@ -106,6 +165,22 @@ class Forge:
|
|||
method=self._default_repository_get,
|
||||
async_=True,
|
||||
)
|
||||
host.bridge.add_method(
|
||||
"forge_clone_counter_update",
|
||||
".plugin",
|
||||
in_sign="ss",
|
||||
out_sign="",
|
||||
method=self._update_clone_counter,
|
||||
async_=True,
|
||||
)
|
||||
host.bridge.add_method(
|
||||
"forge_clone_counter_get",
|
||||
".plugin",
|
||||
in_sign="ssss",
|
||||
out_sign="s",
|
||||
method=self._get_clone_counter,
|
||||
async_=True,
|
||||
)
|
||||
|
||||
def _repositories_get(
|
||||
self, target_profile: str, data_s: str, profile: str
|
||||
|
|
@ -128,3 +203,117 @@ class Forge:
|
|||
raise exceptions.NotFound("No default repository set.")
|
||||
else:
|
||||
return defer.succeed(default.model_dump_json())
|
||||
|
||||
@aio
|
||||
async def _update_clone_counter(
|
||||
self, target_profile: str, repository_name: str
|
||||
) -> None:
|
||||
"""Update clone counter for a repository
|
||||
|
||||
Counters are incremented per-day. Local timezone is used to determine the day.
|
||||
|
||||
@param target_profile: name of the profile owning the repository
|
||||
@param repository_name: name of the repository
|
||||
"""
|
||||
key = f"counter:{repository_name}:{dt_date.today().isoformat()}"
|
||||
namespace = PLUGIN_INFO[C.PI_NAME]
|
||||
|
||||
async with G.storage.session() as session:
|
||||
stmt = (
|
||||
insert(PrivateInd)
|
||||
.values(
|
||||
namespace=namespace,
|
||||
key=key,
|
||||
profile_id=G.storage.profiles[target_profile],
|
||||
value="1",
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[
|
||||
PrivateInd.namespace,
|
||||
PrivateInd.key,
|
||||
PrivateInd.profile_id,
|
||||
],
|
||||
set_={"value": literal_column("CAST(value AS INTEGER) + 1")},
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
def _get_clone_counter(
|
||||
self,
|
||||
target_profile: str,
|
||||
repository_name: str,
|
||||
request_s: str,
|
||||
profile: str,
|
||||
) -> defer.Deferred[str]:
|
||||
client = self.host.get_client(profile)
|
||||
request = CloneCounterRequest.model_validate_json(request_s)
|
||||
d = defer.ensureDeferred(
|
||||
self.get_clone_counter(client, target_profile, repository_name, request)
|
||||
)
|
||||
d.addCallback(lambda stats_list: stats_list.model_dump_json())
|
||||
d = cast(defer.Deferred[str], d)
|
||||
return d
|
||||
|
||||
@aio
|
||||
async def get_clone_counter(
|
||||
self,
|
||||
client: SatXMPPEntity,
|
||||
target_profile: str,
|
||||
repository_name: str,
|
||||
request: CloneCounterRequest,
|
||||
) -> CloneCounters:
|
||||
"""Retrieve clone counter.
|
||||
|
||||
@param client: Client instance.
|
||||
@param target_profile: Profile to get counters for.
|
||||
@param repository_name: Name of the repository.
|
||||
@param request: Which counters to retrieve.
|
||||
@return: List of clone counters.
|
||||
"""
|
||||
namespace = PLUGIN_INFO[C.PI_NAME]
|
||||
profile_id = G.storage.profiles[target_profile]
|
||||
key_prefix = f"counter:{repository_name}:"
|
||||
|
||||
# FIXME: We use PrivateInd which has text values, and we use keys to store the
|
||||
# date. This is not ideal. In future, dedicated models/DB tables should probably
|
||||
# be used.
|
||||
|
||||
stmt = (
|
||||
select(PrivateInd)
|
||||
.where(
|
||||
PrivateInd.namespace == namespace,
|
||||
PrivateInd.profile_id == profile_id,
|
||||
)
|
||||
.order_by(PrivateInd.key)
|
||||
)
|
||||
|
||||
if request.date is not None:
|
||||
key = f"{key_prefix}{request.date.isoformat()}"
|
||||
stmt = stmt.where(PrivateInd.key == key)
|
||||
else:
|
||||
if request.start_date is not None:
|
||||
start_key = f"{key_prefix}{request.start_date.isoformat()}"
|
||||
stmt = stmt.where(PrivateInd.key >= start_key)
|
||||
if request.end_date is not None:
|
||||
end_key = f"{key_prefix}{request.end_date.isoformat()}"
|
||||
stmt = stmt.where(PrivateInd.key <= end_key)
|
||||
stmt = stmt.where(PrivateInd.key.like(f"{key_prefix}%"))
|
||||
|
||||
async with G.storage.session() as session:
|
||||
result = await session.execute(stmt)
|
||||
|
||||
rows = result.scalars().all()
|
||||
|
||||
counters = CloneCounters([])
|
||||
for row in rows:
|
||||
date_str = row.key[len(key_prefix) :]
|
||||
counters.append(
|
||||
CloneCounter(
|
||||
repository=repository_name,
|
||||
date=date_str,
|
||||
count=int(row.value),
|
||||
)
|
||||
)
|
||||
|
||||
return counters
|
||||
|
|
|
|||
Loading…
Reference in a new issue