Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Remove usage_count from monthly_permission_stats

Revision ID: 4ec964df6de1
Revises: 95fa30d57cf4
Create Date: 2025-11-17 13:28:32.280361

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '4ec964df6de1'
down_revision = '95fa30d57cf4'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('monthly_permission_stats', 'usage_count')
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('monthly_permission_stats', sa.Column('usage_count', sa.INTEGER(), autoincrement=False, nullable=False))
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Add monthly_permission_stats table

Revision ID: 78f53b3e0bc0
Revises: b664044af9f3
Create Date: 2025-11-17 11:32:54.542349

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '78f53b3e0bc0'
down_revision = 'b664044af9f3'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('monthly_permission_stats',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('app_id', sa.String(), nullable=False),
sa.Column('month', sa.String(), nullable=False),
sa.Column('permission', sa.String(), nullable=False),
sa.Column('usage_count', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_monthly_permission_stats_app_id'), 'monthly_permission_stats', ['app_id'], unique=False)
op.create_index('ix_monthly_permission_stats_app_id_month_permission', 'monthly_permission_stats', ['app_id', 'month', 'permission'], unique=True)
op.create_index(op.f('ix_monthly_permission_stats_month'), 'monthly_permission_stats', ['month'], unique=False)
op.create_index(op.f('ix_monthly_permission_stats_permission'), 'monthly_permission_stats', ['permission'], unique=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_monthly_permission_stats_permission'), table_name='monthly_permission_stats')
op.drop_index(op.f('ix_monthly_permission_stats_month'), table_name='monthly_permission_stats')
op.drop_index('ix_monthly_permission_stats_app_id_month_permission', table_name='monthly_permission_stats')
op.drop_index(op.f('ix_monthly_permission_stats_app_id'), table_name='monthly_permission_stats')
op.drop_table('monthly_permission_stats')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Add permission_value to monthly_permission_stats

Revision ID: 95fa30d57cf4
Revises: 78f53b3e0bc0
Create Date: 2025-11-17 13:25:54.461824

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '95fa30d57cf4'
down_revision = '78f53b3e0bc0'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('monthly_permission_stats', sa.Column('permission_value', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('monthly_permission_stats', 'permission_value')
# ### end Alembic commands ###
68 changes: 68 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3061,6 +3061,74 @@ def set_all_mappings(cls, db, mappings: dict[str, str]) -> None:
db.commit()


class MonthlyPermissionStats(Base):
__tablename__ = "monthly_permission_stats"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
app_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
month: Mapped[str] = mapped_column(
String, nullable=False, index=True
) # Format: YYYY-MM
permission: Mapped[str] = mapped_column(String, nullable=False, index=True)
# usage_count removed; permission_value stores the actual permission data
permission_value: Mapped[Any] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)

__table_args__ = (
Index(
"ix_monthly_permission_stats_app_id_month_permission",
"app_id",
"month",
"permission",
unique=True,
),
)

@classmethod
def upsert(
cls, db, app_id: str, month: str, permission: str, permission_value: Any = None
):
stat = (
db.query(cls)
.filter_by(app_id=app_id, month=month, permission=permission)
.first()
)
if stat:
stat.permission_value = permission_value
else:
stat = cls(
app_id=app_id,
month=month,
permission=permission,
permission_value=permission_value,
)
db.add(stat)
db.commit()
return stat

@classmethod
def get_stats_for_month(cls, db, month: str):
return db.query(cls).filter_by(month=month).all()

@classmethod
def get_stats_for_app(cls, db, app_id: str):
return db.query(cls).filter_by(app_id=app_id).all()

@classmethod
def get_stats_for_permission(cls, db, permission: str):
return db.query(cls).filter_by(permission=permission).all()

@classmethod
def get_stats(cls, db, app_id: str, month: str, permission: str):
return (
db.query(cls)
.filter_by(app_id=app_id, month=month, permission=permission)
.first()
)


class AppStats(Base):
__tablename__ = "app_stats"

Expand Down
50 changes: 48 additions & 2 deletions backend/app/routes/stats.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from fastapi import APIRouter, FastAPI, Path, Response
import datetime

from fastapi import APIRouter, FastAPI, Path, Query, Response
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel

from .. import database, stats
from .. import database, models, stats

router = APIRouter(
prefix="/stats",
Expand Down Expand Up @@ -34,6 +36,50 @@ class StatsResult(BaseModel):
category_totals: list[StatsResultCategoryTotals]


class MonthlyPermissionStatsResult(BaseModel):
app_id: str
month: str
permission: str
# usage_count removed
permission_value: object | None = None
created_at: datetime.datetime


# Endpoint: /stats/permissions?month=YYYY-MM&app_id=...&permission=...
@router.get(
"/permissions",
status_code=200,
response_model=list[MonthlyPermissionStatsResult],
responses={
200: {"description": "Monthly permission statistics"},
},
)
def get_monthly_permission_stats(
month: str = Query(None, description="Month in YYYY-MM format"),
app_id: str = Query(None, description="App ID to filter"),
permission: str = Query(None, description="Permission to filter"),
):
with database.get_db() as db:
query = db.query(models.MonthlyPermissionStats)
if month:
query = query.filter(models.MonthlyPermissionStats.month == month)
if app_id:
query = query.filter(models.MonthlyPermissionStats.app_id == app_id)
if permission:
query = query.filter(models.MonthlyPermissionStats.permission == permission)
results = query.all()
return [
MonthlyPermissionStatsResult(
app_id=r.app_id,
month=r.month,
permission=r.permission,
permission_value=r.permission_value,
created_at=r.created_at,
)
for r in results
]


class StatsResultApp(BaseModel):
installs_total: int
installs_per_day: dict[str, int]
Expand Down
33 changes: 33 additions & 0 deletions backend/app/worker/monthly_permission_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from datetime import datetime

import dramatiq

from .. import cron, database, models


def get_current_month():
return datetime.now().strftime("%Y-%m")


@cron.cron("0 4 1 * *") # Run at 4am on the first day of each month
@dramatiq.actor
def update_monthly_permission_stats():
with database.get_db("writer") as db:
appids = database.get_all_appids_for_frontend()
month = get_current_month()
for app_id in appids:
app = db.query(models.App).filter(models.App.app_id == app_id).first()
if not app or not app.summary:
continue
metadata = app.summary.get("metadata", {})
permissions = metadata.get("permissions", {})
if not permissions:
continue
for perm_type, perm_value in permissions.items():
models.MonthlyPermissionStats.upsert(
db, app_id, month, perm_type, perm_value
)


if __name__ == "__main__":
update_monthly_permission_stats()
Loading
Loading