From 073b9c513c01ec3df3e69ded51c882157d0331d3 Mon Sep 17 00:00:00 2001 From: sverben <59171289+sverben@users.noreply.github.com> Date: Wed, 17 Apr 2024 20:50:35 +0200 Subject: [PATCH] feat: smoelen --- api/Dockerfile | 2 +- api/app/conf.py | 2 + api/app/db/crud.py | 234 ++++++++++++------ api/app/db/models.py | 36 ++- api/app/db/schemas.py | 32 ++- api/app/main.py | 48 +++- api/app/migrate.py | 4 +- api/app/requirements.txt | 5 +- api/app/utils.py | 174 +++++++++++++ .../versions/5dff294b1b4c_smoelen.py | 47 ++++ api/start.sh | 3 + client/src/__generated__/api.ts | 77 +++++- client/src/lib/components/AlbumView.svelte | 227 +++++++++++++++++ client/src/lib/components/Modal.svelte | 83 +++++++ client/src/lib/components/ModeSwitch.svelte | 32 +++ client/src/lib/routes/Album.svelte | 205 +-------------- client/src/lib/routes/Albums.svelte | 2 + client/src/lib/routes/Smoel.svelte | 27 ++ client/src/lib/routes/Smoelen.svelte | 85 +++++++ client/src/routes.ts | 4 + 20 files changed, 1041 insertions(+), 288 deletions(-) create mode 100644 api/app/utils.py create mode 100644 api/migrations/versions/5dff294b1b4c_smoelen.py create mode 100644 api/start.sh create mode 100644 client/src/lib/components/AlbumView.svelte create mode 100644 client/src/lib/components/Modal.svelte create mode 100644 client/src/lib/components/ModeSwitch.svelte create mode 100644 client/src/lib/routes/Smoel.svelte create mode 100644 client/src/lib/routes/Smoelen.svelte diff --git a/api/Dockerfile b/api/Dockerfile index 7f6df63..a77c2c7 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,4 +12,4 @@ WORKDIR /app COPY . /app ENV PYTHONPATH=/ -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"] +ENTRYPOINT ["/app/start.sh"] diff --git a/api/app/conf.py b/api/app/conf.py index cfd2c12..fb99f1d 100644 --- a/api/app/conf.py +++ b/api/app/conf.py @@ -8,6 +8,8 @@ class Settings(BaseSettings): allowed_users: list[str] openid_configuration: str database_url: str + client_id: str + client_secret: str settings = Settings() diff --git a/api/app/db/crud.py b/api/app/db/crud.py index dbaf90d..b65672d 100644 --- a/api/app/db/crud.py +++ b/api/app/db/crud.py @@ -1,6 +1,7 @@ import datetime import os from functools import lru_cache +from typing import Type from uuid import uuid4, UUID import ffmpeg @@ -8,11 +9,12 @@ from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 from fastapi import UploadFile -from app.fileresponse import FastApiBaizeFileResponse as FileResponse -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload +from sqlalchemy import func from app.conf import settings from app.db import models, schemas +from app.fileresponse import FastApiBaizeFileResponse as FileResponse @lru_cache() @@ -41,10 +43,10 @@ def sign_item(item_data: models.Item): expiry = (datetime.datetime.now() + datetime.timedelta(days=1)).timestamp() item.cover_path = sign_url( - f"{settings.base_url}/items/{item_data.album_id}/{item.id}/{expiry}/cover" + f"{settings.base_url}/items/{item.id}/{expiry}/cover" ) item.path = sign_url( - f"{settings.base_url}/items/{item_data.album_id}/{item.id}/{expiry}/full" + f"{settings.base_url}/items/{item.id}/{expiry}/full" ) return item @@ -61,6 +63,17 @@ def get_album(db: Session, album_id: UUID): return album +def get_smoel_album(db: Session, album_id: UUID): + smoel_data = db.query(models.Smoel).filter(models.Smoel.id == album_id).first() + smoel = schemas.SmoelAlbum.model_validate(smoel_data) + smoel.items = [] + + for item in smoel_data.items: + smoel.items.append(sign_item(item)) + + return smoel + + def get_full(db: Session, item_id: UUID): item = db.query(models.Item).filter(models.Item.id == item_id).first() @@ -87,6 +100,24 @@ def get_albums(db: Session): return result +def get_smoelen_albums(db: Session): + smoelen = sorted(db.query(models.Smoel).all(), key=lambda x: len(x.items), reverse=True) + result = [] + + for smoel_data in smoelen: + smoel = schemas.SmoelAlbumList.model_validate(smoel_data) + if smoel_data.preview_id: + smoel.preview = sign_item(smoel_data.preview) + + smoel.items = [] + for item in smoel_data.items[:2]: + smoel.items.append(sign_item(item)) + + result.append(smoel) + + return result + + def create_album(db: Session, album: schemas.AlbumCreate): album_id = uuid4() os.mkdir(f"data/items/{album_id}") @@ -119,98 +150,132 @@ def order_albums(db: Session, albums: list[schemas.AlbumOrder]): return db.query(models.Album).all() -async def create_item( +def create_item( db: Session, - user: schemas.User, - items: list[UploadFile], - album_id: UUID, - date: datetime = None, + user: schemas.User | None, + item: bytes, + content_type: str, + album_id: UUID | None, + date: datetime = None ): - db_items = [] - - for item in items: - # write temp file - item_id = uuid4() - os.mkdir(f"data/items/{album_id}/{item_id}") - with open(f"/tmp/{item_id}", "wb") as buffer: - buffer.write(await item.read()) - - # get type - file_type = item.content_type.split("/")[0] - if file_type not in ["image", "video"]: - continue - - if file_type == "image": - file_type = models.Type.IMAGE - else: - file_type = models.Type.VIDEO + # write temp file + item_id = uuid4() + if album_id is None: + album_folder = "data/items/smoelen" + if not os.path.exists(album_folder): + os.mkdir(album_folder) + + else: + album_folder = f"data/items/{album_id}" + + os.mkdir(f"{album_folder}/{item_id}") + with open(f"/tmp/{item_id}", "wb") as buffer: + buffer.write(item) + + # get type + file_type = content_type.split("/")[0] + if file_type not in ["image", "video"]: + return None + + if file_type == "image": + file_type = models.Type.IMAGE + else: + file_type = models.Type.VIDEO + + # get metadata + probe = ffmpeg.probe(f"/tmp/{item_id}") + width = probe["streams"][0]["width"] + height = probe["streams"][0]["height"] + + # create cover image + cover_path = f"{album_folder}/{item_id}/cover.jpg" + + if file_type == models.Type.VIDEO: + stream = ffmpeg.input(f"/tmp/{item_id}") + stream = ffmpeg.filter(stream, "scale", 400, -1) + stream = ffmpeg.output(stream, cover_path, vframes=1) + ffmpeg.run(stream) + else: + stream = ffmpeg.input(f"/tmp/{item_id}") + stream = ffmpeg.filter(stream, "scale", 400, -1) + stream = ffmpeg.output(stream, cover_path) + ffmpeg.run(stream) + + # store optimized full size image/video + if file_type == models.Type.VIDEO: + path = f"{album_folder}/{item_id}/item.mp4" + + stream = ffmpeg.input(f"/tmp/{item_id}") + stream = ffmpeg.output(stream, path, crf=23) + ffmpeg.run(stream) + else: + path = f"{album_folder}/{item_id}/item.jpg" + + stream = ffmpeg.input(f"/tmp/{item_id}") + stream = ffmpeg.output(stream, path) + ffmpeg.run(stream) + + if user: + user_id = user.id + else: + user_id = None + + db_item = models.Item( + id=item_id, + user=user_id, + album_id=album_id, + type=file_type, + width=width, + height=height, + cover_path=cover_path, + path=path, + date=date, + ) + db.add(db_item) + db.commit() + db.refresh(db_item) - # get metadata - probe = ffmpeg.probe(f"/tmp/{item_id}") - width = probe["streams"][0]["width"] - height = probe["streams"][0]["height"] + os.remove(f"/tmp/{item_id}") + return db_item - # create cover image - cover_path = f"data/items/{album_id}/{item_id}/cover.jpg" - if file_type == models.Type.VIDEO: - stream = ffmpeg.input(f"/tmp/{item_id}") - stream = ffmpeg.filter(stream, "scale", 400, -1) - stream = ffmpeg.output(stream, cover_path, vframes=1) - ffmpeg.run(stream) - else: - stream = ffmpeg.input(f"/tmp/{item_id}") - stream = ffmpeg.filter(stream, "scale", 400, -1) - stream = ffmpeg.output(stream, cover_path) - ffmpeg.run(stream) - - # store optimized full size image/video - if file_type == models.Type.VIDEO: - path = f"data/items/{album_id}/{item_id}/item.mp4" - - stream = ffmpeg.input(f"/tmp/{item_id}") - stream = ffmpeg.output(stream, path, crf=23) - ffmpeg.run(stream) - else: - path = f"data/items/{album_id}/{item_id}/item.jpg" - - stream = ffmpeg.input(f"/tmp/{item_id}") - stream = ffmpeg.output(stream, path) - ffmpeg.run(stream) - - db_item = models.Item( - id=item_id, - user=user.id, - album_id=album_id, - type=file_type, - width=width, - height=height, - cover_path=cover_path, - path=path, - date=date, - ) - db.add(db_item) - db.commit() - db.refresh(db_item) - db_items.append(db_item) +async def create_items( + db: Session, + user: schemas.User | None, + items: list[UploadFile], + album_id: UUID | None, + date: datetime = None, +) -> list[models.Item]: + db_items = [] - os.remove(f"/tmp/{item_id}") + for item in items: + db_item = create_item(db, user, await item.read(), item.content_type, album_id, date) + if db_item is not None: + db_items.append(db_item) return db_items -def delete_items(db: Session, user: schemas.User, album_id: UUID, items: list[UUID]): +def delete_items(db: Session, user: schemas.User | None, album_id: UUID | None, items: list[UUID]): for item in items: db_item = db.query(models.Item).filter(models.Item.id == item).first() - if db_item.user != user.id and not user.admin: + if user is not None and db_item.user != user.id and not user.admin: continue os.remove(db_item.path) os.remove(db_item.cover_path) - os.rmdir(f"data/items/{album_id}/{item}") + + if album_id is None: + album_folder = "data/items/smoelen" + else: + album_folder = f"data/items/{album_id}" + os.rmdir(f"{album_folder}/{item}") db.delete(db_item) db.commit() + if not album_id: + return None + return db.query(models.Album).filter(models.Album.id == album_id).first() @@ -224,3 +289,18 @@ def set_preview(db: Session, album_id: UUID, item_id: UUID): db.commit() return db_album + + +def get_smoel(db: Session, smoel_id: UUID, name: str): + smoel = db.query(models.Smoel).filter(models.Smoel.id == smoel_id).first() + if not smoel: + smoel = models.Smoel(name=name, id=smoel_id) + db.add(smoel) + db.commit() + + return smoel + + +def set_smoel(db: Session, item: models.Item | Type[models.Item], smoel: models.Smoel): + smoel.items.append(item) + db.commit() diff --git a/api/app/db/models.py b/api/app/db/models.py index ffe262c..070e4bd 100644 --- a/api/app/db/models.py +++ b/api/app/db/models.py @@ -1,8 +1,12 @@ +from __future__ import annotations + import enum +from typing import List -from sqlalchemy import Column, ForeignKey, String, Uuid, Enum, Integer, DateTime +from sqlalchemy import Column, ForeignKey, String, Uuid, Enum, Integer, DateTime, Boolean, Table +from sqlalchemy.orm import Mapped from sqlalchemy.orm import relationship -from sqlalchemy.sql import func +from sqlalchemy.sql import func, expression from app.db.database import Base @@ -30,15 +34,41 @@ class Album(Base): ) +association_table = Table( + "association", + Base.metadata, + Column("item_id", Uuid, ForeignKey("items.id")), + Column("smoel_id", Uuid, ForeignKey("smoelen.id")), +) + + +class Smoel(Base): + __tablename__ = "smoelen" + id = Column(Uuid, primary_key=True, index=True) + name = Column(String) + + preview_id = Column(Uuid, ForeignKey("items.id"), nullable=True) + preview = relationship("Item", foreign_keys=[preview_id]) + + items: Mapped[List[Item]] = relationship( + secondary=association_table, back_populates="smoelen" + ) + + class Item(Base): __tablename__ = "items" id = Column(Uuid, primary_key=True, index=True) user = Column(String) - album_id = Column(Uuid, ForeignKey("albums.id")) + album_id = Column(Uuid, ForeignKey("albums.id"), nullable=True) album = relationship("Album", back_populates="items", foreign_keys=[album_id]) date = Column(DateTime(timezone=True), server_default=func.now()) + processed = Column(Boolean, server_default=expression.false(), nullable=False) + smoelen: Mapped[List[Smoel]] = relationship( + secondary=association_table, back_populates="items" + ) + type = Column(Enum(Type)) width = Column(String) height = Column(String) diff --git a/api/app/db/schemas.py b/api/app/db/schemas.py index c290718..48824c0 100644 --- a/api/app/db/schemas.py +++ b/api/app/db/schemas.py @@ -21,18 +21,27 @@ def sign_url(url: str): return f"{url}?signature={signature.hex()}" +class Smoel(BaseModel): + id: UUID + name: str + + class Config: + from_attributes = True + + class ItemBase(BaseModel): id: UUID date: datetime width: int height: int type: int - user: str + user: str | None class Item(ItemBase): path: str cover_path: str + smoelen: list[Smoel] class Config: from_attributes = True @@ -43,6 +52,10 @@ class AlbumBase(BaseModel): description: str +class SmoelAlbumBase(BaseModel): + name: str + + class AlbumCreate(AlbumBase): pass @@ -57,6 +70,14 @@ class Config: from_attributes = True +class SmoelAlbum(SmoelAlbumBase): + id: UUID + items: list[Item] + + class Config: + from_attributes = True + + class AlbumList(AlbumBase): id: UUID order: int @@ -66,6 +87,15 @@ class Config: from_attributes = True +class SmoelAlbumList(SmoelAlbumBase): + id: UUID + preview: Item + items: list[Item] + + class Config: + from_attributes = True + + class AlbumOrder(BaseModel): id: UUID order: int diff --git a/api/app/main.py b/api/app/main.py index 6e59ca4..82aee03 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from datetime import datetime from functools import lru_cache from typing import Annotated @@ -6,16 +7,18 @@ import jwt import requests from Crypto.Hash import SHA256 -from fastapi import FastAPI, Depends, UploadFile, status +from fastapi import FastAPI, Depends, UploadFile, status, BackgroundTasks from fastapi.exceptions import HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi_utils.tasks import repeat_every from sqlalchemy.orm import Session from app.conf import settings from app.db import models, schemas, crud from app.db.database import get_db, engine from app.db.schemas import User +from app.utils import process_smoelen, obtain_images, handle_unprocessed @lru_cache() @@ -28,8 +31,22 @@ def get_jwks_client(): return jwt.PyJWKClient(uri=get_openid_configuration()["jwks_uri"]) +@repeat_every(seconds=24 * 60 * 60) +async def update_smoelen(): + db = next(get_db()) + obtain_images(db) + handle_unprocessed(db) + + +@asynccontextmanager +async def startup(_app: FastAPI) -> None: + """Startup context manager""" + await update_smoelen() + yield + + models.Base.metadata.create_all(bind=engine) -app = FastAPI() +app = FastAPI(lifespan=startup) app.add_middleware( CORSMiddleware, @@ -139,18 +156,21 @@ async def update_album( async def upload_items( album_id: UUID, items: list[UploadFile], + background_tasks: BackgroundTasks, db: Session = Depends(get_db), user: User = Depends(get_user), ): - return await crud.create_item(db, user, items, album_id) + items = await crud.create_items(db, user, items, album_id) + background_tasks.add_task(process_smoelen, db, items) + return items -@app.get("/items/{album_id}/{item_id}/{expiry}/full", include_in_schema=False) +@app.get("/items/{item_id}/{expiry}/full", include_in_schema=False) async def get_item( - album_id: UUID, item_id: UUID, signature: str, expiry: float, db: Session = Depends(get_db) + item_id: UUID, signature: str, expiry: float, db: Session = Depends(get_db) ): if not verify_signature( - f"{settings.base_url}/items/{album_id}/{item_id}/{expiry}/full", signature + f"{settings.base_url}/items/{item_id}/{expiry}/full", signature ): return None if datetime.now().timestamp() > expiry: @@ -159,12 +179,12 @@ async def get_item( return crud.get_full(db, item_id) -@app.get("/items/{album_id}/{item_id}/{expiry}/cover", include_in_schema=False) +@app.get("/items/{item_id}/{expiry}/cover", include_in_schema=False) async def get_cover( - album_id: UUID, item_id: UUID, signature: str, expiry: float, db: Session = Depends(get_db) + item_id: UUID, signature: str, expiry: float, db: Session = Depends(get_db) ): if not verify_signature( - f"{settings.base_url}/items/{album_id}/{item_id}/{expiry}/cover", signature + f"{settings.base_url}/items/{item_id}/{expiry}/cover", signature ): return None if datetime.now().timestamp() > expiry: @@ -209,3 +229,13 @@ async def set_preview( @app.get("/users/me", response_model=User, operation_id="get_user") async def get_user(user: User = Depends(get_user)): return user + + +@app.get("/smoelen", response_model=list[schemas.SmoelAlbumList], operation_id="get_smoelen") +async def get_smoelen(db: Session = Depends(get_db), _user=Depends(get_user)): + return crud.get_smoelen_albums(db) + + +@app.get("/smoelen/{smoel_id}", response_model=schemas.SmoelAlbum, operation_id="get_smoel") +async def get_smoel(smoel_id: UUID, db: Session = Depends(get_db), _user=Depends(get_user)): + return crud.get_smoel_album(db, smoel_id) diff --git a/api/app/migrate.py b/api/app/migrate.py index e3375b3..bf2e238 100644 --- a/api/app/migrate.py +++ b/api/app/migrate.py @@ -1,6 +1,6 @@ from json import loads from db.database import engine -from db.crud import create_album, create_item, set_preview +from db.crud import create_album, create_items, set_preview from db.schemas import AlbumCreate, User, Item from fastapi import UploadFile from datetime import datetime @@ -21,7 +21,7 @@ async def migrate(): headers = Headers({ "Content-Type": f"{file['type']}/webp", }) - item = await create_item( + item = await create_items( database, User(id=file["user"], admin=False), [UploadFile(buffer, headers=headers)], diff --git a/api/app/requirements.txt b/api/app/requirements.txt index fbeeb7e..5ded0df 100644 --- a/api/app/requirements.txt +++ b/api/app/requirements.txt @@ -2,7 +2,7 @@ uvicorn~=0.29.0 ffmpeg-python~=0.2.0 fastapi~=0.110.0 SQLAlchemy~=2.0.29 -pydantic~=2.6.4 +pydantic~=2.7.0 cryptography~=42.0.5 pydantic-settings~=2.2.1 PyJWT~=2.8.0 @@ -11,3 +11,6 @@ pycryptodome~=3.20.0 python-multipart~=0.0.9 baize~=0.20.8 alembic~=1.13.1 +face-recognition~=1.3.0 +weaviate-client~=4.5.5 +fastapi-utils~=0.2.1 diff --git a/api/app/utils.py b/api/app/utils.py new file mode 100644 index 0000000..a730791 --- /dev/null +++ b/api/app/utils.py @@ -0,0 +1,174 @@ +from enum import Enum +from hashlib import md5 +from io import BytesIO +from typing import List +from urllib.request import urlopen +from uuid import UUID + +import face_recognition +import requests +from PIL import Image +from numpy import asarray, ndarray +from sqlalchemy.orm import Session +from weaviate import connect_to_local + +from app.conf import settings +from app.db import models +from app.db.crud import get_smoel, set_smoel, delete_items, create_item + +client = connect_to_local() +known_faces = client.collections.get('known_faces') + + +class FaceAction(Enum): + NONE = 0 + CREATE = 1 + UPDATE = 2 + + +def create_encodings(face): + if isinstance(face, str): + image = Image.open(face).convert('RGB') + else: + image = Image.open(BytesIO(face)).convert('RGB') + data = asarray(image) + + locations = face_recognition.face_locations(data) + encodings = face_recognition.face_encodings(data, locations, num_jitters=20) + + return encodings + + +def obtain_access_token(): + result = requests.post("https://leden.djoamersfoort.nl/o/token/", data={ + "grant_type": "client_credentials", + "client_id": settings.client_id, + "client_secret": settings.client_secret, + }).json() + + return result["access_token"] + + +def parse_smoel(smoel): + with urlopen(smoel["photo"]) as response: + data = response.read() + + return { + "id": UUID(None, None, None, None, smoel["id"]), + "name": smoel["first_name"], + "data": data, + "hash": md5(data).hexdigest() + } + + +def get_action(user): + current_result = requests.get(f"http://localhost:8080/v1/objects/known_faces/{user['id']}") + + if current_result.status_code != 200: + return FaceAction.CREATE + + current = current_result.json() + # Sometimes Weaviate decides to turn hashes into UUIDs + if "".join(current["properties"]["hash"].split("-")) != user["hash"]: + return FaceAction.UPDATE + + return FaceAction.NONE + + +def store_encoding(db: Session, user): + action = get_action(user) + if action == FaceAction.NONE: + return + + encoding = create_encodings(user["data"]) + if len(encoding) == 0: + return + + smoel = get_smoel(db, user["id"], user["name"]) + smoel_vector = ndarray.tolist(encoding[0]) + if action == FaceAction.CREATE: + requests.post("http://localhost:8080/v1/objects", json={ + "class": "known_faces", + "id": str(user["id"]), + "vector": smoel_vector, + "properties": { + "hash": user["hash"], + "user": str(user["id"]), + "name": user["name"], + } + }) + elif action == FaceAction.UPDATE: + requests.put(f"http://localhost:8080/v1/objects/known_faces/{user['id']}", json={ + "vector": smoel_vector, + "properties": { + "hash": user["hash"], + "user": str(user["id"]), + "name": user["name"], + } + }) + + if smoel.preview_id is not None: + preview = smoel.preview_id + smoel.preview_id = None + delete_items(db, None, None, [preview]) + + item = create_item(db, None, user["data"], "image/png", None) + if item is not None: + item.processed = True + smoel.preview_id = item.id + + db.commit() + + +def obtain_images(db: Session): + print("Updating smoel index") + smoelen = requests.get("https://leden.djoamersfoort.nl/api/v1/smoelenboek?large=1/", headers={ + "Authorization": f"Bearer {obtain_access_token()}" + }).json() + + for smoel in smoelen: + user = parse_smoel(smoel) + store_encoding(db, user) + + print("Finished updating smoel index") + + +def find_people(item: models.Item): + encodings = create_encodings(item.path) + smoelen = [] + for encoding in encodings: + smoel = known_faces.query.near_vector( + near_vector=ndarray.tolist(encoding), + distance=0.18, + limit=1, + ) + if len(smoel.objects) == 0: + continue + + smoelen.append(smoel.objects[0].properties) + + return smoelen + + +def process_smoelen(db: Session, images: List[models.Item]): + print("processing smoelen") + for item in images: + if item.type != models.Type.IMAGE: + item.processed = True + db.commit() + continue + + smoelen = find_people(item) + for smoel in smoelen: + db_smoel = get_smoel(db, smoel["user"], smoel["name"]) + set_smoel(db, item, db_smoel) + + item.processed = True + db.commit() + + print("done processing smoelen") + + +def handle_unprocessed(db: Session): + items = db.query(models.Item).where(models.Item.processed == False).all() + process_smoelen(db, items) diff --git a/api/migrations/versions/5dff294b1b4c_smoelen.py b/api/migrations/versions/5dff294b1b4c_smoelen.py new file mode 100644 index 0000000..9dec717 --- /dev/null +++ b/api/migrations/versions/5dff294b1b4c_smoelen.py @@ -0,0 +1,47 @@ +"""smoelen + +Revision ID: 5dff294b1b4c +Revises: d870455eec92 +Create Date: 2024-04-17 20:38:11.285331 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5dff294b1b4c' +down_revision: Union[str, None] = 'd870455eec92' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('smoelen', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('preview_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['preview_id'], ['items.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_smoelen_id'), 'smoelen', ['id'], unique=False) + op.create_table('association', + sa.Column('item_id', sa.Uuid(), nullable=True), + sa.Column('smoel_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['smoel_id'], ['smoelen.id'], ) + ) + op.add_column('items', sa.Column('processed', sa.Boolean(), server_default=sa.text('0'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'processed') + op.drop_table('association') + op.drop_index(op.f('ix_smoelen_id'), table_name='smoelen') + op.drop_table('smoelen') + # ### end Alembic commands ### diff --git a/api/start.sh b/api/start.sh new file mode 100644 index 0000000..fe2d06b --- /dev/null +++ b/api/start.sh @@ -0,0 +1,3 @@ +#!/bin/sh +alembic upgrade head +uvicorn app.main:app --host 0.0.0.0 diff --git a/client/src/__generated__/api.ts b/client/src/__generated__/api.ts index c5a45af..bb6e385 100644 --- a/client/src/__generated__/api.ts +++ b/client/src/__generated__/api.ts @@ -93,11 +93,51 @@ export interface Item { /** Type */ type: number; /** User */ - user: string; + user: string | null; /** Path */ path: string; /** Cover Path */ cover_path: string; + /** Smoelen */ + smoelen: Smoel[]; +} + +/** Smoel */ +export interface Smoel { + /** + * Id + * @format uuid + */ + id: string; + /** Name */ + name: string; +} + +/** SmoelAlbum */ +export interface SmoelAlbum { + /** Name */ + name: string; + /** + * Id + * @format uuid + */ + id: string; + /** Items */ + items: Item[]; +} + +/** SmoelAlbumList */ +export interface SmoelAlbumList { + /** Name */ + name: string; + /** + * Id + * @format uuid + */ + id: string; + preview: Item; + /** Items */ + items: Item[]; } /** User */ @@ -510,4 +550,39 @@ export class Api extends HttpClient + this.request({ + path: `/smoelen`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @name GetSmoel + * @summary Get Smoel + * @request GET:/smoelen/{smoel_id} + * @secure + */ + getSmoel: (smoelId: string, params: RequestParams = {}) => + this.request({ + path: `/smoelen/${smoelId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; } diff --git a/client/src/lib/components/AlbumView.svelte b/client/src/lib/components/AlbumView.svelte new file mode 100644 index 0000000..a54abcd --- /dev/null +++ b/client/src/lib/components/AlbumView.svelte @@ -0,0 +1,227 @@ + + +{#if $current} +
+ + {#if isAlbum} + {#await user}{:then user} + {#if user.admin || user.id === $current.user} + + {/if} + {#if user.admin} + + {/if} + {/await} + {/if} + + + + + + + +

Smoelen

+ {#if $current.smoelen.length > 0} +
    + {#each $current.smoelen as smoel (smoel.id)} +
  • {smoel.name}
  • + {/each} +
+ {:else} +

(Nog) geen smoelen gevonden

+ {/if} +
+
+{:else} + + {#if isAlbum} + {#await user}{:then user} + {#if user.admin} + push(`/${id}/edit`)} /> + {/if} + {/await} + + {/if} + + + {#if selecting && isAlbum} +
+
+ + Delete +
+
+ {/if} +{/if} +{#if uploading} + +{/if} + + diff --git a/client/src/lib/components/Modal.svelte b/client/src/lib/components/Modal.svelte new file mode 100644 index 0000000..5d8283f --- /dev/null +++ b/client/src/lib/components/Modal.svelte @@ -0,0 +1,83 @@ + + + + (showModal = false)} + on:click|self={() => dialog.close()} +> + +
+
+ +
+
+ + + +
+
+ + diff --git a/client/src/lib/components/ModeSwitch.svelte b/client/src/lib/components/ModeSwitch.svelte new file mode 100644 index 0000000..4ced11f --- /dev/null +++ b/client/src/lib/components/ModeSwitch.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/client/src/lib/routes/Album.svelte b/client/src/lib/routes/Album.svelte index e9e1d93..6bfcf20 100644 --- a/client/src/lib/routes/Album.svelte +++ b/client/src/lib/routes/Album.svelte @@ -1,208 +1,27 @@ {#await album}

Loading...

{:then album} - {#if $current} -
- - {#await user}{:then user} - {#if user.admin || user.id === $current.user} - - {/if} - {#if user.admin} - - {/if} - {/await} - - - - - -
- {:else} - - {#await user}{:then user} - {#if user.admin} - push(`/${album.id}/edit`)} /> - {/if} - {/await} - - - - {#if selecting} -
-
- - Delete -
-
- {/if} - {/if} - {#if uploading} - - {/if} + {/await} - - diff --git a/client/src/lib/routes/Albums.svelte b/client/src/lib/routes/Albums.svelte index 3b9a1ff..f66486e 100644 --- a/client/src/lib/routes/Albums.svelte +++ b/client/src/lib/routes/Albums.svelte @@ -9,6 +9,7 @@ import { dndzone } from 'svelte-dnd-action' import { faPlus } from "@fortawesome/free-solid-svg-icons" import { push } from "svelte-spa-router" + import ModeSwitch from "$lib/components/ModeSwitch.svelte"; let albums = Api.albums.getAlbums() .then(({ data }) => { @@ -49,6 +50,7 @@ {/each} {/await} + diff --git a/client/src/routes.ts b/client/src/routes.ts index da45d06..e0f804e 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -6,9 +6,13 @@ import Album from '$lib/routes/Album.svelte' import Edit from '$lib/routes/Edit.svelte' import { user } from '$lib/stores' +import Smoelen from "$lib/routes/Smoelen.svelte"; +import Smoel from "$lib/routes/Smoel.svelte"; export default { '/': Albums, + '/smoelen': Smoelen, + '/smoelen/:smoel': Smoel, '/create': wrap({ component: Create, conditions: [