Skip to content

Commit

Permalink
feat: Added announcements
Browse files Browse the repository at this point in the history
Added announcements (also known as motd) that will be used by decky
to show important messages to users.
  • Loading branch information
gbdlin committed Aug 22, 2024
1 parent bbc27bb commit cdfa5b8
Show file tree
Hide file tree
Showing 14 changed files with 553 additions and 21 deletions.
74 changes: 72 additions & 2 deletions plugin_store/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from functools import reduce
from operator import add
from os import getenv
from typing import Optional
from typing import Annotated, Optional

import fastapi
from fastapi import Depends, FastAPI, HTTPException, Request
Expand All @@ -14,13 +14,15 @@
from cdn import upload_image, upload_version
from constants import SortDirection, SortType, TEMPLATES_DIR
from database.database import database, Database
from database.models import Announcement
from discord import post_announcement

from .models import announcements as api_announcements
from .models import delete as api_delete
from .models import list as api_list
from .models import submit as api_submit
from .models import update as api_update
from .utils import FormBody, getIpHash
from .utils import FormBody, getIpHash, UUID7

app = FastAPI()

Expand Down Expand Up @@ -66,6 +68,74 @@ async def index():
return INDEX_PAGE


@app.get(
"/v1/announcements",
dependencies=[Depends(auth_token)],
response_model=list[api_announcements.AnnouncementResponse],
)
async def list_announcements(
db: Annotated["Database", Depends(database)],
):
return await db.list_announcements(active=False)


@app.post(
"/v1/announcements",
dependencies=[Depends(auth_token)],
response_model=api_announcements.AnnouncementResponse,
status_code=fastapi.status.HTTP_201_CREATED,
)
async def create_announcement(
db: Annotated["Database", Depends(database)],
announcement: api_announcements.AnnouncementRequest,
):
return await db.create_announcement(title=announcement.title, text=announcement.text)


@app.get("/v1/announcements/-/current", response_model=list[api_announcements.CurrentAnnouncementResponse])
async def list_current_announcements(
db: Annotated["Database", Depends(database)],
):
return await db.list_announcements()


@app.get(
"/v1/announcements/{announcement_id}",
dependencies=[Depends(auth_token)],
response_model=api_announcements.AnnouncementResponse,
)
async def get_announcement(
db: Annotated["Database", Depends(database)],
announcement_id: UUID7,
):
return await db.get_announcement(announcement_id)


@app.put(
"/v1/announcements/{announcement_id}",
dependencies=[Depends(auth_token)],
response_model=api_announcements.AnnouncementResponse,
)
async def update_announcement(
db: Annotated["Database", Depends(database)],
existing_announcement: Annotated["Announcement", Depends(get_announcement)],
new_announcement: api_announcements.AnnouncementRequest,
):
return await db.update_announcement(existing_announcement, title=new_announcement.title, text=new_announcement.text)


@app.delete(
"/v1/announcements/{announcement_id}",
dependencies=[Depends(auth_token)],
status_code=fastapi.status.HTTP_204_NO_CONTENT,
)
async def delete_announcement(
db: Annotated["Database", Depends(database)],
announcement_id: UUID7,
):
await db.delete_announcement(announcement_id)


@app.get("/plugins", response_model=list[api_list.ListPluginResponse])
async def plugins_list(
query: str = "",
Expand Down
37 changes: 37 additions & 0 deletions plugin_store/api/models/announcements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from datetime import datetime

from ..utils import UUID7
from .base import BaseModel


class CurrentAnnouncementResponse(BaseModel):
class Config:
orm_mode = True

id: UUID7

title: str
text: str

created: datetime
updated: datetime


class AnnouncementResponse(BaseModel):
class Config:
orm_mode = True

id: UUID7

title: str
text: str

active: bool

created: datetime
updated: datetime


class AnnouncementRequest(BaseModel):
title: str
text: str
5 changes: 5 additions & 0 deletions plugin_store/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from fastapi import File, Form, Request, UploadFile
from fastapi.params import Depends
from pydantic import UUID1


def getIpHash(request: Request):
Expand Down Expand Up @@ -35,3 +36,7 @@ def __init__(self, model: Any = None, *, use_cache: bool = True):
# noinspection PyPep8Naming
def FormBody(model: Any = None, *, use_cache: bool = True) -> Any:
return FormBodyCls(model=model, use_cache=use_cache)


class UUID7(UUID1):
_required_version = 7
51 changes: 51 additions & 0 deletions plugin_store/database/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import datetime
from os import getenv
from typing import Optional, TYPE_CHECKING
from uuid import UUID
from zoneinfo import ZoneInfo

from alembic import command
Expand All @@ -16,6 +17,7 @@

from constants import SortDirection, SortType

from .models.announcements import Announcement
from .models.Artifact import Artifact, PluginTag, Tag
from .models.Version import Version

Expand Down Expand Up @@ -67,6 +69,55 @@ def init(self):
alembic_cfg = Config("/alembic.ini")
command.upgrade(alembic_cfg, "head")

async def list_announcements(self, active: bool = True):
statement = select(Announcement)
if not active:
statement = statement.where(Announcement.active == True)
statement = statement.order_by(desc(Announcement.created))
result = (await self.session.execute(statement)).scalars().all()
return result or []

async def get_announcement(self, announcement_id: UUID) -> Announcement | None:
statement = select(Announcement).where(Announcement.id == announcement_id)
try:
return (await self.session.execute(statement)).scalars().first()
except NoResultFound:
return None

async def create_announcement(self, title: str, text: str) -> Announcement | None:
nested = await self.session.begin_nested()
async with self.lock:
announcement = Announcement(
title=title,
text=text,
)
try:
self.session.add(announcement)
except Exception:
await nested.rollback()
raise
await self.session.commit()
return await self.get_announcement(announcement.id)

async def update_announcement(self, announcement: Announcement, **kwargs) -> Announcement | None:
nested = await self.session.begin_nested()
async with self.lock:
if "title" in kwargs:
announcement.title = kwargs["title"]
if "text" in kwargs:
announcement.text = kwargs["text"]
try:
self.session.add(announcement)
except Exception:
await nested.rollback()
raise
await self.session.commit()
return await self.get_announcement(announcement.id)

async def delete_announcement(self, announcement_id: UUID) -> None:
await self.session.execute(delete(Announcement).where(Announcement.id == announcement_id))
await self.session.commit()

async def prepare_tags(self, session: "AsyncSession", tag_names: list[str]) -> "list[Tag]":
try:
statement = select(Tag).where(Tag.tag.in_(tag_names)).order_by(Tag.id)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""empty message
Revision ID: 469f48c143b9
Revises: f5a91a25a410
Create Date: 2024-08-10 21:58:11.798321
"""

import sqlalchemy as sa
from alembic import op

from database import utils

# revision identifiers, used by Alembic.
revision = "469f48c143b9"
down_revision = "f5a91a25a410"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"announcements",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("updated", utils.TZDateTime(), nullable=False),
sa.Column("created", utils.TZDateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("announcements")
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions plugin_store/database/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from .announcements import Announcement
from .Artifact import Artifact, PluginTag, Tag
from .Base import Base
from .Version import Version

__all__ = [
"Announcement",
"Artifact",
"Base",
"PluginTag",
Expand Down
29 changes: 29 additions & 0 deletions plugin_store/database/models/announcements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime
from uuid import UUID
from zoneinfo import ZoneInfo

from sqlalchemy import Boolean, Column, Text, Uuid
from sqlalchemy.orm import Mapped, mapped_column

from ..utils import TZDateTime, uuid7
from .Base import Base

UTC = ZoneInfo("UTC")


def utcnow() -> datetime:
return datetime.now(UTC)


class Announcement(Base):
__tablename__ = "announcements"

id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7)

title: Mapped[str] = Column(Text, nullable=False)
text: Mapped[str] = Column(Text, nullable=False)

active: Mapped[bool] = Column(Boolean, nullable=False)

created: Mapped[datetime] = Column(TZDateTime, nullable=False, default=utcnow)
updated: Mapped[datetime] = Column(TZDateTime, nullable=False, default=utcnow, onupdate=utcnow)
68 changes: 68 additions & 0 deletions plugin_store/database/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID
from zoneinfo import ZoneInfo

from sqlalchemy import DateTime
Expand Down Expand Up @@ -35,3 +37,69 @@ def process_result_value(self, value: "Any | None", dialect: "Dialect") -> "date

def __repr__(self):
return "TZDateTime()"


_last_timestamp_v7 = None
_last_counter_v7 = 0 # 42-bit counter


def uuid7():
"""Generate a UUID from a Unix timestamp in milliseconds and random bits.
UUIDv7 objects feature monotonicity within a millisecond.
"""
# --- 48 --- -- 4 -- --- 12 --- -- 2 -- --- 30 --- - 32 -
# unix_ts_ms | version | counter_hi | variant | counter_lo | random
#
# 'counter = counter_hi | counter_lo' is a 42-bit counter constructed
# with Method 1 of RFC 9562, §6.2, and its MSB is set to 0.
#
# 'random' is a 32-bit random value regenerated for every new UUID.
#
# If multiple UUIDs are generated within the same millisecond, the LSB
# of 'counter' is incremented by 1. When overflowing, the timestamp is
# advanced and the counter is reset to a random 42-bit integer with MSB
# set to 0.

def get_counter_and_tail():
rand = int.from_bytes(os.urandom(10))
# 42-bit counter with MSB set to 0
rand_counter = (rand >> 32) & 0x1FFFFFFFFFF
# 32-bit random data
rand_tail = rand & 0xFFFFFFFF
return rand_counter, rand_tail

global _last_timestamp_v7
global _last_counter_v7

import time

nanoseconds = time.time_ns()
timestamp_ms, _ = divmod(nanoseconds, 1_000_000)

if _last_timestamp_v7 is None or timestamp_ms > _last_timestamp_v7:
counter, tail = get_counter_and_tail()
else:
if timestamp_ms < _last_timestamp_v7:
timestamp_ms = _last_timestamp_v7 + 1
# advance the counter
counter = _last_counter_v7 + 1
if counter > 0x3FFFFFFFFFF:
timestamp_ms += 1 # advance the timestamp
counter, tail = get_counter_and_tail()
else:
tail = int.from_bytes(os.urandom(4))

_last_timestamp_v7 = timestamp_ms
_last_counter_v7 = counter

int_uuid_7 = (timestamp_ms & 0xFFFFFFFFFFFF) << 80
int_uuid_7 |= ((counter >> 30) & 0xFFF) << 64
int_uuid_7 |= (counter & 0x3FFFFFFF) << 32
int_uuid_7 |= tail & 0xFFFFFFFF
# Set the variant to RFC 4122.
int_uuid_7 &= ~(0xC000 << 48)
int_uuid_7 |= 0x8000 << 48

# Set the version number to 7.
int_uuid_7 |= 0x7000 << 64
return UUID(int=int_uuid_7)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ env = [
"DB_URL=sqlite+aiosqlite:///:memory:",
"SUBMIT_AUTH_KEY=deadbeef",
]
asyncio_mode = "auto"
Loading

0 comments on commit cdfa5b8

Please sign in to comment.