78 Commits

Author SHA1 Message Date
Thomas Peetz e2ff26f6bf Setup MessageModerator as Docker build
refs #28
2025-07-16 16:04:33 +02:00
Thomas Peetz 0d99f383fb add MediaFile from queue 2025-07-11 16:49:42 +02:00
Thomas Peetz c6fd80408b read queue 2025-07-10 22:04:31 +02:00
Thomas Peetz d2d4deb350 set messages durable 2025-07-10 15:58:11 +02:00
tpeetz ee280e0b8e Merge branch 'feature/27-use-amqp-protocol-for-messages' into 'develop/0.2.0'
use AMQP protocol for messages

Closes #27

See merge request tpeetz/kontor!30
2025-07-07 15:52:04 +02:00
Thomas Peetz 1c2c2f38a4 use message for adding links 2025-07-07 15:49:33 +02:00
Thomas Peetz b5cca50960 add_link.py sends message 2025-07-06 22:46:47 +02:00
tpeetz 0a505fabcf Merge branch 'feature/25-integrate-apache-camel' into 'develop/0.2.0'
integrate apache camel

Closes #25 and #26

See merge request tpeetz/kontor!29
2025-07-04 00:29:48 +02:00
Thomas Peetz 4b8053c6e2 fix configuration to connect to Artemis from Sprint Boot for all environments 2025-07-04 00:29:15 +02:00
Thomas Peetz 5df3c1c47e integrate Apache Camel into Spring Boot 2025-07-03 17:42:30 +02:00
Thomas Peetz adad4f88da configure actuator endpoints 2025-07-03 11:31:40 +02:00
Thomas Peetz 957c7a702e Extend docker-compose.yml network configuration for camel-karavan 2025-07-03 07:33:24 +02:00
tpeetz b5344a9ed1 Merge branch 'feature/24-add-docker-image-for-apache-camel-caravan' into 'develop/0.2.0'
Add Docker image for Apache Camel Caravan

Closes #24

See merge request tpeetz/kontor!28
2025-07-01 22:28:37 +02:00
Thomas Peetz 16ad701eed Extend docker-compose.yml for camel-karavan, registry and gitea 2025-07-01 22:23:24 +02:00
Thomas Peetz 2ba7465675 change port and credentials for messages 2025-07-01 07:01:34 +02:00
tpeetz c703dfc6e3 Merge branch 'feature/23-add-docker-image-for-apache-activemq' into 'develop/0.2.0'
Add Docker image for Apache ActiveMQ

Closes #23

See merge request tpeetz/kontor!26
2025-06-30 17:39:10 +02:00
Thomas Peetz b62cc89f71 add Apache ActiveMQ Artemis as Docker image to docker-compose.yml 2025-06-30 17:33:39 +02:00
tpeetz e741a46f69 Merge branch 'feature/22-add-docker-image-for-couchdb' into 'develop/0.2.0'
Add Docker image for CouchDB

Closes #22

See merge request tpeetz/kontor!25
2025-06-30 16:42:08 +02:00
Thomas Peetz 4f87ff83ce add CouchDB as Docker image to docker-compose.yml 2025-06-30 16:38:15 +02:00
Thomas Peetz 4871f56320 use new SearchField for Comics 2025-06-23 22:17:45 +02:00
Thomas Peetz ec404c9956 remove servicemix 2025-06-23 12:48:07 +02:00
tpeetz fc9db8200f Merge branch 'feature/6-enhance-the-search-field' into 'develop/0.2.0'
Enhance the search field by adding options to filter for boolean fields

See merge request tpeetz/kontor!22
2025-06-23 12:39:03 +02:00
tpeetz 69caa825de Enhance the search field by adding options to filter for boolean fields 2025-06-23 12:39:03 +02:00
Thomas Peetz 123be2e9c0 add script to read queues 2025-06-20 16:57:17 +02:00
Thomas Peetz 928536b414 add servicemix container build 2025-06-17 23:35:35 +02:00
Thomas Peetz 8ed8599f17 add missing import 2025-06-16 08:08:30 +02:00
Thomas Peetz 435de2ec92 fix handling of id 2025-06-15 05:31:42 +02:00
Thomas Peetz 529a199262 add add_link.py and improve hamndling of MediaFile 2025-06-14 18:53:15 +02:00
Thomas Peetz 510e2f8130 make buttons submit and cancel work 2025-06-13 01:11:18 +02:00
Thomas Peetz da4f286180 remove unused imports 2025-06-11 18:56:33 +02:00
Thomas Peetz d9136e45f6 WIP: add HTML form for editing comics 2025-06-09 23:38:55 +02:00
Thomas Peetz b610947403 change loading config for export.py 2025-06-08 20:23:30 +02:00
Thomas Peetz 6e520a46f0 add display of issue title in lists 2025-06-07 21:39:01 +02:00
Thomas Peetz b4a0c2d7a5 make details for Comic, Artist and Issue clickable, add CustomField to select Comic and Issue 2025-06-05 17:58:27 +02:00
Thomas Peetz ea9f596abe add IssueWork entity with Repository, Form and View 2025-06-04 21:36:26 +02:00
Thomas Peetz 4bb7d61f80 add comparison of datetime objects with strings 2025-06-04 14:06:18 +02:00
Thomas Peetz cc8a166f5c change log.info to log.debug 2025-06-03 13:38:49 +02:00
Thomas Peetz a70bf8ae96 fix selecting Volume for Issue 2025-06-03 10:18:36 +02:00
tpeetz 902ee03e3f Merge branch 'feature/19-create-component-for-selecting-year-and-month' into 'develop/0.2.0'
Resolve "Create component for selecting year and month"

Closes #19

See merge request tpeetz/kontor!19
2025-06-02 22:31:26 +02:00
Thomas Peetz f1f49ab014 use CustomField to create combined year and month field 2025-06-02 22:30:28 +02:00
Thomas Peetz 7a16225c3b add custom component MonthYearPicker 2025-06-02 16:39:03 +02:00
Thomas Peetz 8e13af5b8c add handling of None values 2025-06-02 08:53:40 +02:00
tpeetz 767c069404 Merge branch 'feature/18-remove-entity-moduledata' into 'develop/0.2.0'
Remove entity ModuleData and import data for comic, admin and tysc module

Closes #18

See merge request tpeetz/kontor!20
2025-06-01 22:29:04 +02:00
Thomas Peetz dd5281e2a1 remove entity ModuleData from python code
refs #18
2025-06-01 22:27:42 +02:00
Thomas Peetz aefd56d1ff Remove entity ModuleData and setup data
refs #18
2025-06-01 22:23:25 +02:00
tpeetz 293a0b3478 remove dependency to MariaDB and improve import of field published_on 2025-06-01 20:23:15 +02:00
tpeetz 076466b895 remove dependency to MariaDB 2025-06-01 19:56:01 +02:00
tpeetz 370738ff14 Merge branch 'feature/16-add-publication-date' into 'develop/0.2.0'
Add field published_on and title to Issue

See merge request tpeetz/kontor!18
2025-06-01 18:37:19 +02:00
Thomas Peetz 1151b0e45e Add field published_on and title to Issue
/refs #16

Add field published_on and title to Issue and display both fields.
2025-06-01 18:32:15 +02:00
Thomas Peetz 7c5c571716 add form for editing artist 2025-05-27 18:08:17 +02:00
Thomas Peetz 1b636efdb7 change logging 2025-05-26 11:35:51 +02:00
Thomas Peetz 374c242890 deactivate authentication 2025-05-25 23:10:05 +02:00
Thomas Peetz 87b1c24783 move card components to module specific directories 2025-05-25 22:12:23 +02:00
Thomas Peetz 456162da44 remove MetaDataTable and MetaDataColumn to cleanup schema 2025-05-23 18:21:41 +02:00
Thomas Peetz 8cfb60f9a1 add missing fields for import 2025-05-21 23:24:13 +02:00
Thomas Peetz 4c70046a32 removed fields from MetaDataColumn 2025-05-21 17:29:14 +02:00
Thomas Peetz 546f8ebbdf separate modules for worktype endpoints 2025-05-20 14:06:13 +02:00
Thomas Peetz bd86379d07 add missing fields for comic 2025-05-19 15:14:29 +02:00
Thomas Peetz edcaed1b1a fix problem when adding columns 2025-05-19 14:34:26 +02:00
Thomas Peetz 20ed0b2f40 add method for import data 2025-05-19 14:16:30 +02:00
Thomas Peetz 3197288eee add update for existing items 2025-05-18 15:44:13 +02:00
Thomas Peetz 84d64f04d2 check existing items with data from file 2025-05-15 13:46:05 +02:00
tpeetz 1ecf64a228 update model for import 2025-05-14 19:36:15 +02:00
Thomas Peetz 41d513e402 add reference from Volume to Issue and Comic 2025-05-13 21:40:29 +02:00
Thomas Peetz aa4b47e032 change display of Comics from cards to table 2025-05-13 07:57:58 +02:00
Thomas Peetz 3537642df9 add CRUD for WorkType 2025-05-13 00:42:41 +02:00
Thomas Peetz 06a48a03ac add display for WorkType 2025-05-09 01:17:35 +02:00
Thomas Peetz d60e606663 add display for MediaVideo 2025-05-05 22:13:51 +02:00
Thomas Peetz 13dad3961c add display for MetaData 2025-05-04 12:27:03 +02:00
Thomas Peetz 4cf1941f44 add issue display 2025-05-02 18:17:06 +02:00
Thomas Peetz ace568a800 setup changed 2025-05-02 13:58:09 +02:00
Thomas Peetz c77adb0e04 update models to use string type for id fields 2025-05-02 11:21:57 +02:00
Thomas Peetz 7ff2bf912d update scripts to use Postgres 2025-05-01 01:13:49 +02:00
Thomas Peetz 72c1a7d265 use PostgreSQL for kontor-spring and kontor-api 2025-04-30 22:11:48 +02:00
Thomas Peetz 2efacf6d67 Merge branch 'main' into develop/0.2.0 2025-04-30 19:32:42 +02:00
Thomas Peetz d6549132ea update version to 0.2.0 2025-04-30 19:24:29 +02:00
Thomas Peetz bbf422cc5d create Docker images only with development version as tag 2025-04-29 14:43:07 +02:00
Thomas Peetz 4c96de27db import sources from develop/0.1.0 2025-04-29 12:52:55 +02:00
236 changed files with 6082 additions and 4660 deletions
+5
View File
@@ -13,4 +13,9 @@ kontor-schema/kontor_schema.egg-info
kontor-gui/.pdm-python kontor-gui/.pdm-python
kontor-gui/dist kontor-gui/dist
fastapi/.coverage fastapi/.coverage
kontor-api/.coverage
db-password.txt db-password.txt
kontor-api/tests/test_main.py
kontor-api/tests/test_db.db
kontor-api/test_db.db
couchdb-password.txt
+3 -2
View File
@@ -1,9 +1,10 @@
kontor_api := kontor-api kontor_api := kontor-api
kontor_spring := kontor-spring kontor_spring := kontor-spring
kontor_servicemix := kontor-servicemix
.PHONY: all $(kontor_spring) $(kontor_api) .PHONY: all $(kontor_spring) $(kontor_api)
all: $(kontor_spring) $(kontor_api) all: $(kontor_spring) $(kontor_api) $(kontor_servicemix)
$(kontor_spring) $(kontor_api): $(kontor_spring) $(kontor_api) $(kontor_servicemix):
$(MAKE) --directory=$@ $(TARGET) $(MAKE) --directory=$@ $(TARGET)
+72 -15
View File
@@ -1,44 +1,101 @@
services: services:
mariadb: postgres:
image: mariadb image: postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: kontor - POSTGRES_DB=kontor
MYSQL_USER: kontor - POSTGRES_USER=kontor
MYSQL_PASSWORD: kontor #- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
MYSQL_DATABASE: kontor - POSTGRES_PASSWORD=kontor
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kontor"]
interval: 1s
timeout: 5s
retries: 10
ports: ports:
- 3316:3306 - 5432:5432
networks: networks:
- database - database
volumes: volumes:
- mariadb-storage:/var/lib/mysql:rw - postgres-data:/var/lib/postgresql/data:rw
secrets:
- db-password
adminer:
image: adminer
ports:
- 8090:8080
networks:
- database
- frontend
couchdb:
image: couchdb
restart: unless-stopped
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=admin
ports:
- 5984:5984
networks:
- database
- frontend
volumes:
- couchdb-data:/opt/couchdb/data
secrets:
- couchdb-password
activemq:
image: apache/activemq-artemis:latest-alpine
restart: unless-stopped
ports:
- 61616:61616
- 8161:8161
- 5672:5672
networks:
- integration
- frontend
volumes:
- activemq-data:/var/lib/artemis-instance
kontor: kontor:
image: kontor image: kontor:0.2.0-SNAPSHOT
restart: unless-stopped restart: unless-stopped
networks: networks:
- database - database
- integration
- frontend - frontend
ports: ports:
- 8000:8000 - 8000:8000
volumes:
- images-data:/data/images
depends_on: depends_on:
- mariadb postgres:
condition: service_healthy
kontor-api: kontor-api:
image: kontor-api image: kontor-api:0.2.0-SNAPSHOT
restart: unless-stopped restart: unless-stopped
networks: networks:
- database - database
- integration
- frontend - frontend
ports: ports:
- 8800:8800 - 8800:8800
volumes:
- images-data:/data/images
depends_on: depends_on:
- mariadb postgres:
condition: service_healthy
networks: networks:
database: database:
integration:
name: integration
frontend: frontend:
volumes: volumes:
mariadb-storage: postgres-data:
couchdb-data:
activemq-data:
images-data:
secrets:
db-password:
file: db-password.txt
couchdb-password:
file: couchdb-password.txt
Binary file not shown.
+3
View File
@@ -1 +1,4 @@
.env .env
.coverage
app.log
+1 -3
View File
@@ -1,8 +1,7 @@
## ------------------------------- Builder Stage ------------------------------ ## ## ------------------------------- Builder Stage ------------------------------ ##
FROM python:3.13-bookworm AS builder FROM python:3.13-bookworm AS builder
RUN apt-get update && apt-get install --no-install-recommends -y \ RUN apt-get update && apt-get install --no-install-recommends -y build-essential && \
build-essential libmariadb-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/* apt-get clean && rm -rf /var/lib/apt/lists/*
# Download the latest installer, install it and then remove it # Download the latest installer, install it and then remove it
@@ -42,7 +41,6 @@ WORKDIR /app
COPY /src src COPY /src src
COPY --from=builder /app/.venv .venv COPY --from=builder /app/.venv .venv
COPY --from=builder /usr/lib/x86_64-linux-gnu/libmariadb.so.3 /usr/lib/x86_64-linux-gnu
# Set up environment variables for production # Set up environment variables for production
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
+3 -3
View File
@@ -4,11 +4,11 @@ clean:
find . -name '*.py[co]' -delete find . -name '*.py[co]' -delete
test: test:
DB_HOST=localhost uv run pytest -v --cov --cov-report=term --cov-report=html:coverage-report DB_SERVER=localhost uv run pytest -v --cov --cov-report=term --cov-report=html:coverage-report
docker: clean docker: clean
docker build --target=production -t kontor-api -t kontor-api:0.1.0 -t kontor-api . docker build --target=production -t kontor-api:0.2.0-SNAPSHOT .
dev: dev:
MARIADB_SERVER=localhost uv run fastapi dev src/main.py --port 8008 DB_SERVER=127.0.0.1 uv run fastapi dev src/main.py --port 8008
+7 -1
View File
@@ -8,7 +8,6 @@ dependencies = [
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
"fastapi[standard]>=0.115.12", "fastapi[standard]>=0.115.12",
"httpx==0.24.1", "httpx==0.24.1",
"mariadb>=1.1.12",
"sqlalchemy>=2.0.40", "sqlalchemy>=2.0.40",
"platformdirs>=4.3.7", "platformdirs>=4.3.7",
"pathlib>=1.0.1", "pathlib>=1.0.1",
@@ -22,4 +21,11 @@ dependencies = [
"python-jose>=3.4.0", "python-jose>=3.4.0",
"python-multipart>=0.0.20", "python-multipart>=0.0.20",
"natsort>=8.4.0", "natsort>=8.4.0",
"psycopg2-binary>=2.9.10",
"pytest-cov>=6.1.1",
"databases[sqlite]>=0.9.0",
"pydantic[email]>=2.11.3",
"jinja2>=3.1.6",
"asyncpg>=0.30.0",
"bcrypt>=4.3.0",
] ]
+2 -1
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.apis.version1 import comic, media, tysc from src.apis.version1 import comic, media, tysc, admin
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
api_router.include_router(comic.router, prefix="/comics", tags=["comics"]) api_router.include_router(comic.router, prefix="/comics", tags=["comics"])
api_router.include_router(media.router, prefix="/media", tags=["media"]) api_router.include_router(media.router, prefix="/media", tags=["media"])
api_router.include_router(tysc.router, prefix="/tysc", tags=["tysc"]) api_router.include_router(tysc.router, prefix="/tysc", tags=["tysc"])
api_router.include_router(admin.router, prefix="/login", tags=["login"])
+40
View File
@@ -1,4 +1,13 @@
from typing import Annotated from typing import Annotated
from typing import Dict
from typing import Optional
from fastapi import HTTPException
from fastapi import Request
from fastapi import status
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param
from fastapi import Depends from fastapi import Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,3 +15,34 @@ from sqlalchemy.orm import Session
from src.db.session import get_db from src.db.session import get_db
SessionDep = Annotated[Session, Depends(get_db)] SessionDep = Annotated[Session, Depends(get_db)]
class OAuth2PasswordBearerWithCookie(OAuth2):
def __init__(
self,
tokenUrl: str,
scheme_name: Optional[str] = None,
scopes: Optional[Dict[str, str]] = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes})
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.cookies.get(
"access_token"
) # changed to accept access token from httpOnly Cookie
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param
+69
View File
@@ -0,0 +1,69 @@
import logging
from datetime import timedelta
import bcrypt
from fastapi import APIRouter, HTTPException, status, Response, Depends
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt, JWTError
from src.apis.utils import SessionDep, OAuth2PasswordBearerWithCookie
from src.core.config import settings
from src.core.security import create_access_token
from src.db.models.admin import Profile
from src.db.repository.admin import get_profile
from src.schema.admin import Token
router = APIRouter()
def authenticate_user(username: str, password: str, db: SessionDep) -> Profile | None:
user = get_profile(username=username, db=db)
print(user)
if not user:
return None
if bcrypt.checkpw(password.encode(), user.password.encode()):
print("User successful authenticated")
else:
logging.info("Authentication failed!")
return user
@router.post("/token", response_model=Token)
def login_for_access_token(response: Response, db: SessionDep, form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(form_data.username, form_data.password, db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
response.set_cookie(
key="access_token", value=f"Bearer {access_token}", httponly=True
)
return {"access_token": access_token, "token_type": "bearer"}
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/login/token")
def get_current_user_from_token(db: SessionDep, token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
username: str = payload.get("sub")
print("username/email extracted is ", username)
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_profile(username=username, db=db)
if user is None:
raise credentials_exception
return user
+18 -13
View File
@@ -1,31 +1,28 @@
from uuid import UUID from typing import List, AnyStr
from typing import List
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from src.apis.utils import SessionDep from src.apis.utils import SessionDep
from src.db.repository.comics.artist import get_artist_details
from src.db.repository.comics.comic import list_comics, get_issue_details
from src.schema.comics.comic import ComicResponse, ComicDetailsResponse, get_comic_details, get_short_info from src.schema.comics.comic import ComicResponse, ComicDetailsResponse, get_comic_details, get_short_info
from src.schema.comics.artist import ArtistCreation, ArtistDetailResponse, ArtistResponse, get_artist_details from src.schema.comics.artist import ArtistCreation, ArtistDetailResponse, ArtistResponse
from src.db.models.comic import Comic, Artist from src.db.models.comic import Comic, Artist, Issue
from src.schema.comics.issue import IssueDetailsResponse
router = APIRouter( router = APIRouter()
prefix="/comic",
tags=["comics"],
responses={404: {"description": "Not found"}},
)
@router.get("/comics") @router.get("/comics")
def get_all_comics(db: SessionDep) -> List[ComicResponse]: def get_all_comics(db: SessionDep) -> List[ComicResponse]:
results: List[ComicResponse] = [] results: List[ComicResponse] = []
comics = db.scalars(select(Comic)).all() comics = list_comics(db)
for comic in comics: for comic in comics:
response = get_short_info(comic) response = get_short_info(comic)
results.append(response) results.append(response)
return results return results
@router.get("/comics/{comic_id}", response_model=ComicDetailsResponse) @router.get("/comics/{comic_id}", response_model=ComicDetailsResponse)
def get_comic(comic_id: UUID, db: SessionDep) -> ComicDetailsResponse: def get_comic(comic_id: AnyStr, db: SessionDep) -> ComicDetailsResponse:
comic = db.get(Comic, comic_id) comic = db.get(Comic, comic_id)
if comic is None: if comic is None:
raise HTTPException(status_code=404, detail="Comic could not be found") raise HTTPException(status_code=404, detail="Comic could not be found")
@@ -41,7 +38,7 @@ def get_all_artists(db: SessionDep) -> List[ArtistResponse]:
return results return results
@router.get("/artists/{artist_id}", response_model=ArtistDetailResponse) @router.get("/artists/{artist_id}", response_model=ArtistDetailResponse)
def get_artist(artist_id: UUID, db: SessionDep) -> ArtistDetailResponse: def get_artist(artist_id: AnyStr, db: SessionDep) -> ArtistDetailResponse:
artist = db.get(Artist, artist_id) artist = db.get(Artist, artist_id)
if artist is None: if artist is None:
raise HTTPException(status_code=404, detail="Artist could not be found") raise HTTPException(status_code=404, detail="Artist could not be found")
@@ -60,3 +57,11 @@ def add_artist(db: SessionDep, artist_creation: ArtistCreation) -> ArtistRespons
response = ArtistResponse(id=artist.id, name=artist.name) response = ArtistResponse(id=artist.id, name=artist.name)
return response return response
@router.get("/issues", response_model=List[IssueDetailsResponse])
def get_issues(db: SessionDep) -> List[IssueDetailsResponse]:
results: List[IssueDetailsResponse] = []
issues = db.query(Issue).all()
for issue in issues:
results.append(get_issue_details(issue))
return results
+13 -20
View File
@@ -1,22 +1,19 @@
from typing import List from typing import List, AnyStr
from uuid import UUID
from fastapi import APIRouter, status, HTTPException from fastapi import APIRouter, status, HTTPException, Depends
from sqlalchemy import select, Sequence from sqlalchemy import select, Sequence
from src.core.log_conf import logger
from src.apis.utils import SessionDep from src.apis.utils import SessionDep
from src.db.repository.media import create_new_mediafile
from src.schema.media.file import MediaFileResponse, Link, get_file_details, set_file from src.schema.media.file import MediaFileResponse, Link, get_file_details, set_file
from src.db.models.media import MediaFile from src.db.models.media import MediaFile
router = APIRouter( router = APIRouter()
prefix="/media",
tags=["media"]
)
@router.get("/update-titles") @router.get("/update-titles")
def update_titles(db: SessionDep) -> list[MediaFileResponse]: def update_titles(db: SessionDep) -> list[MediaFileResponse]:
results: list[MediaFileResponse] = [] results: list[MediaFileResponse] = []
files = db.query(MediaFile).filter(MediaFile.review == 1).all() files = db.query(MediaFile).filter(MediaFile.review == True).all()
for mediafile in files: for mediafile in files:
mediafile.update_title() mediafile.update_title()
db.add(mediafile) db.add(mediafile)
@@ -27,13 +24,14 @@ def update_titles(db: SessionDep) -> list[MediaFileResponse]:
@router.get("/files", response_model=List[MediaFileResponse]) @router.get("/files", response_model=List[MediaFileResponse])
#def get_all_files(db: SessionDep, review: bool = False, download: bool = False, current_user: Profile = Depends(get_current_user_from_token)) -> List[MediaFileResponse]:
def get_all_files(db: SessionDep, review: bool = False, download: bool = False) -> List[MediaFileResponse]: def get_all_files(db: SessionDep, review: bool = False, download: bool = False) -> List[MediaFileResponse]:
results: list[MediaFileResponse] = [] results: list[MediaFileResponse] = []
files: Sequence[MediaFile] files: Sequence[MediaFile]
if review: if review:
files = db.query(MediaFile).filter(MediaFile.review == 1).all() files = db.query(MediaFile).filter(MediaFile.review == True).all()
elif download: elif download:
files = db.query(MediaFile).filter(MediaFile.should_download == 1).all() files = db.query(MediaFile).filter(MediaFile.should_download == True).all()
else: else:
files = db.scalars(select(MediaFile)).all() files = db.scalars(select(MediaFile)).all()
for mediafile in files: for mediafile in files:
@@ -42,7 +40,7 @@ def get_all_files(db: SessionDep, review: bool = False, download: bool = False)
return results return results
@router.get("/files/{file_id}", response_model=MediaFileResponse) @router.get("/files/{file_id}", response_model=MediaFileResponse)
def get_file(file_id: UUID, db: SessionDep) -> MediaFileResponse: def get_file(file_id: AnyStr, db: SessionDep) -> MediaFileResponse:
mediafile = db.get(MediaFile, file_id) mediafile = db.get(MediaFile, file_id)
if not mediafile: if not mediafile:
raise HTTPException(status_code=404, detail="MediaFile could not be found") raise HTTPException(status_code=404, detail="MediaFile could not be found")
@@ -50,7 +48,7 @@ def get_file(file_id: UUID, db: SessionDep) -> MediaFileResponse:
return response return response
@router.put("/files/{file_id}", response_model=MediaFileResponse) @router.put("/files/{file_id}", response_model=MediaFileResponse)
def update_file(file_id: UUID, db: SessionDep, info: MediaFileResponse) -> MediaFileResponse: def update_file(file_id: AnyStr, db: SessionDep, info: MediaFileResponse) -> MediaFileResponse:
mediaFile = db.get(MediaFile, file_id) mediaFile = db.get(MediaFile, file_id)
if not mediaFile: if not mediaFile:
raise HTTPException(status_code=404, detail="MediaFile could not be found") raise HTTPException(status_code=404, detail="MediaFile could not be found")
@@ -62,14 +60,9 @@ def update_file(file_id: UUID, db: SessionDep, info: MediaFileResponse) -> Media
@router.post("/files", status_code=status.HTTP_201_CREATED) @router.post("/files", status_code=status.HTTP_201_CREATED)
def add_file(new_link: Link, db: SessionDep) -> MediaFileResponse: def add_file(new_link: Link, db: SessionDep) -> MediaFileResponse:
print(new_link.url) logger.info(f"add url {new_link.url}")
try: try:
mediaFile: MediaFile = MediaFile() mediaFile: MediaFile = create_new_mediafile(new_link.url, db)
setattr(mediaFile, "url", new_link.url)
setattr(mediaFile, "review", 1)
setattr(mediaFile, "should_download", 1)
db.add(mediaFile)
db.commit()
except: except:
raise HTTPException(status_code=409, detail="Link duplicate") raise HTTPException(status_code=409, detail="Link duplicate")
response = get_file_details(mediaFile) response = get_file_details(mediaFile)
+1 -5
View File
@@ -5,11 +5,7 @@ from src.apis.utils import SessionDep
from src.schema.tysc.sport import SportResponse from src.schema.tysc.sport import SportResponse
from src.db.models.tysc import Sport from src.db.models.tysc import Sport
router = APIRouter( router = APIRouter()
prefix="/tysc",
tags=["tysc"],
responses={404: {"description": "Not found"}},
)
@router.get("/sports") @router.get("/sports")
def get_all_sports(db: SessionDep) -> List[SportResponse]: def get_all_sports(db: SessionDep) -> List[SportResponse]:
+10 -8
View File
@@ -9,15 +9,17 @@ load_dotenv(dotenv_path=env_path)
class Settings: class Settings:
PROJECT_NAME: str = "Kontor" PROJECT_NAME: str = "Kontor"
PROJECT_VERSION: str = "0.1.0" PROJECT_VERSION: str = "0.2.0"
MARIADB_USER: str = os.getenv("MARIADB_USER", "kontor")
MARIADB_PASSWORD: str = os.getenv("MARIADB_PASSWORD", "kontor")
MARIADB_SERVER: str = os.getenv("MARIADB_SERVER", "mariadb")
MARIADB_PORT: str = os.getenv("MARIADB_PORT", 3306)
MARIADB_DB: str = os.getenv("MARIADB_DB", "kontor")
DATABASE_URL: str = f"mariadb+mariadbconnector://{MARIADB_USER}:{MARIADB_PASSWORD}@{MARIADB_SERVER}:{MARIADB_PORT}/{MARIADB_DB}"
DB_USER: str = os.getenv("DB_USER", "kontor")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "kontor")
DB_SERVER: str = os.getenv("DB_SERVER", "postgres")
DB_PORT: str = os.getenv("DB_PORT", 5432)
DB_DBNAME: str = os.getenv("DB_DBNAME", "kontor")
DATABASE_URL: str = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_SERVER}:{DB_PORT}/{DB_DBNAME}"
SECRET_KEY: str = os.getenv("SECRET_KEY", "J6GOtcwC2NJI1l0VkHu20PacPFGTxpirBxWwynoHjsc=")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 600 # in mins
settings = Settings() settings = Settings()
+44
View File
@@ -0,0 +1,44 @@
import logging
import logging.config
from typing import Any
LOGGING_CONFIG: dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(asctime)s - %(name)s - %(levelprefix)s %(message)s"
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(asctime)s - %(name)s - %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s', # noqa: E501
},
"access_file": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(asctime)s - %(name)s - %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s', # noqa: E501
"use_colors": False,
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
"error": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"root": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn.error": {"level": "INFO"},
"uvicorn.access": {"handlers": ["default"], "level": "INFO", "propagate": False},
},
}
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from datetime import timedelta
from typing import Optional
from src.core.config import settings
from jose import jwt
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
+20 -24
View File
@@ -1,7 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy import Column, ForeignKey, Integer, String, Boolean
from sqlalchemy.dialects.mysql import BIT
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.db.models.base import Base, BaseMixin from src.db.models.base import Base, BaseMixin
@@ -9,12 +8,12 @@ from src.db.models.base import Base, BaseMixin
class Profile(Base, BaseMixin): class Profile(Base, BaseMixin):
__tablename__ = 'profile' __tablename__ = 'profile'
first_name = Column(String(255)) first_name = Column(String)
last_name = Column(String(255)) last_name = Column(String)
user_name = Column(String(255), nullable=False) user_name = Column(String, nullable=False)
email = Column(String(255)) email = Column(String)
password = Column(String(255)) password = Column(String)
enabled = Column(BIT(1)) enabled = Column(Boolean)
assignments = relationship("Assignment") assignments = relationship("Assignment")
tokens = relationship("Token") tokens = relationship("Token")
@@ -28,20 +27,23 @@ class Profile(Base, BaseMixin):
full_name += self.last_name full_name += self.last_name
return full_name return full_name
def __str__(self):
return f"Profile({self.id} {self.user_name}, {self.email})"
class Token(Base, BaseMixin): class Token(Base, BaseMixin):
__tablename__ = "token" __tablename__ = "token"
token = Column(String(255), nullable=False, unique=True) token = Column(String, nullable=False, unique=True)
name = Column(String(255)) name = Column(String)
last_used_date: Mapped[datetime] = mapped_column() last_used_date: Mapped[datetime] = mapped_column()
enabled = Column(BIT(1)) enabled = Column(Boolean)
profile_id = Column(String(255), ForeignKey("profile.id"), nullable=False) profile_id = Column(String, ForeignKey("profile.id"), nullable=False)
profile = relationship("Profile", back_populates="tokens") profile = relationship("Profile", back_populates="tokens")
class Permission(Base, BaseMixin): class Permission(Base, BaseMixin):
__tablename__ = "permission" __tablename__ = "permission"
name = Column(String(255), nullable=False) name = Column(String, nullable=False)
assignments = relationship("Assignment") assignments = relationship("Assignment")
@@ -53,20 +55,14 @@ class Assignment(Base, BaseMixin):
permission = relationship("Permission", back_populates="assignments") permission = relationship("Permission", back_populates="assignments")
class ModuleData(Base, BaseMixin):
__tablename__ = "module_data"
module_name = Column(String(255), nullable=False)
import_data = Column(BIT(1))
class MailAccount(Base, BaseMixin): class MailAccount(Base, BaseMixin):
__tablename__ = "mail_account" __tablename__ = "mail_account"
host = Column(String(255)) host = Column(String)
port = Column(Integer) port = Column(Integer)
protocol = Column(String(255)) protocol = Column(String)
user_name = Column(String(255)) user_name = Column(String)
password = Column(String(255)) password = Column(String)
start_tls = Column(BIT(1)) start_tls = Column(Boolean)
class Mail(Base, BaseMixin): class Mail(Base, BaseMixin):
+10 -11
View File
@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import func, Column, String from sqlalchemy import func, Column, String, Boolean
from sqlalchemy.dialects.mysql import BIT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -11,8 +10,8 @@ class Base(DeclarativeBase):
class BaseMixin: class BaseMixin:
id = Column(String(255), primary_key=True, default=uuid.uuid4()) #id = Column(String, primary_key=True, default=uuid.uuid4)
# id: Mapped[str] = mapped_column(primary_key=True, default=uuid.uuid4()) id: Mapped[str] = mapped_column(primary_key=True, default=str(uuid.uuid4()))
# created_date = Column(DateTime) # created_date = Column(DateTime)
created_date: Mapped[datetime] = mapped_column(default=func.now()) created_date: Mapped[datetime] = mapped_column(default=func.now())
# last_modified_date = Column(DateTime) # last_modified_date = Column(DateTime)
@@ -22,10 +21,10 @@ class BaseMixin:
class BaseVideoMixin: class BaseVideoMixin:
cloud_link = Column(String(255)) cloud_link = Column(String)
file_name = Column(String(255)) file_name = Column(String)
path = Column(String(255)) path = Column(String)
review = Column(BIT(1)) review = Column(Boolean)
title = Column(String(255)) title = Column(String)
url = Column(String(255), unique=True) url = Column(String, unique=True)
should_download = Column(BIT(1)) should_download = Column(Boolean)
+6 -6
View File
@@ -6,28 +6,28 @@ from src.db.models.base import Base, BaseMixin
class Article(Base, BaseMixin): class Article(Base, BaseMixin):
__tablename__ = 'article' __tablename__ = 'article'
title = Column(String(length=255), unique=True) title = Column(String, unique=True)
article_authors = relationship("ArticleAuthor") article_authors = relationship("ArticleAuthor")
class Author(Base, BaseMixin): class Author(Base, BaseMixin):
__tablename__ = 'author' __tablename__ = 'author'
first_name = Column(String(255)) first_name = Column(String)
last_name = Column(String(255)) last_name = Column(String)
article_authors = relationship("ArticleAuthor") article_authors = relationship("ArticleAuthor")
book_authors = relationship("BookAuthor") book_authors = relationship("BookAuthor")
class BookshelfPublisher(Base, BaseMixin): class BookshelfPublisher(Base, BaseMixin):
__tablename__ = 'bookshelf_publisher' __tablename__ = 'bookshelf_publisher'
name = Column(String(length=255), unique=True) name = Column(String, unique=True)
books = relationship("Book") books = relationship("Book")
class Book(Base, BaseMixin): class Book(Base, BaseMixin):
__tablename__ = 'book' __tablename__ = 'book'
isbn = Column(String(255), unique=True) isbn = Column(String, unique=True)
title = Column(String(255)) title = Column(String)
year = Column(Integer, nullable=False) year = Column(Integer, nullable=False)
publisher_id = Column(String, ForeignKey('bookshelf_publisher.id'), nullable=False) publisher_id = Column(String, ForeignKey('bookshelf_publisher.id'), nullable=False)
publisher = relationship('BookshelfPublisher', back_populates="books") publisher = relationship('BookshelfPublisher', back_populates="books")
+84 -23
View File
@@ -1,15 +1,24 @@
from typing import Dict, List import uuid
from datetime import datetime
from typing import AnyStr, Dict, List, Optional, Any
from natsort import natsorted from natsort import natsorted
from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy import Column, ForeignKey, Integer, String, Boolean, func
from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.orm import relationship
from src.db.models.base import Base, BaseMixin from src.db.models.base import Base, BaseMixin
class Publisher(Base, BaseMixin): class Publisher(Base):
__tablename__ = "publisher" __tablename__ = "publisher"
name = Column(String(length=255), unique=True) id: Mapped[str] = mapped_column(primary_key=True, default=uuid.uuid4)
created_date: Mapped[datetime] = mapped_column(default=func.now())
last_modified_date: Mapped[datetime] = mapped_column(default=func.now())
version: Mapped[int] = mapped_column(default=0)
name = Column(String, unique=True)
weblink = Column(String, nullable=True)
parent_publisher_id: Mapped[Optional[str]] = mapped_column(ForeignKey('publisher.id'))
parent_publisher: Mapped[Optional['Publisher']] = relationship("Publisher", back_populates="imprints", remote_side=[id])
imprints: Mapped[List['Publisher']] = relationship('Publisher', back_populates="parent_publisher")
comics = relationship("Comic") comics = relationship("Comic")
def __repr__(self): def __repr__(self):
@@ -21,11 +30,12 @@ class Publisher(Base, BaseMixin):
class Comic(Base, BaseMixin): class Comic(Base, BaseMixin):
__tablename__ = 'comic' __tablename__ = 'comic'
title = Column(String(length=255), unique=True) title = Column(String, unique=True)
publisher_id = Column(String, ForeignKey('publisher.id'), nullable=False) publisher_id = Column(String, ForeignKey('publisher.id'), nullable=False)
publisher = relationship("Publisher", back_populates="comics") publisher = relationship("Publisher", back_populates="comics")
current_order = Column(BIT(1)) current_order = Column(Boolean)
completed = Column(BIT(1)) completed = Column(Boolean)
weblink = Column(String, nullable=True)
issues = relationship("Issue", order_by="Issue.issue_number") issues = relationship("Issue", order_by="Issue.issue_number")
story_arcs = relationship("StoryArc") story_arcs = relationship("StoryArc")
trade_paperbacks = relationship("TradePaperback") trade_paperbacks = relationship("TradePaperback")
@@ -38,10 +48,10 @@ class Comic(Base, BaseMixin):
def __str__(self): def __str__(self):
return f'{self.title}({self.id})' return f'{self.title}({self.id})'
def get_artists(self) -> Dict[str, List[str]]: def get_artists(self) -> Dict[Any, List[Any]]:
works: Dict[str, List[str]] = {} works: Dict[Any, List[Any]] = {}
for work in self.comic_works: for work in self.comic_works:
work_type = work.work_type.name work_type = work.work_type
artist = work.artist artist = work.artist
if work_type in works: if work_type in works:
works[work_type].append(artist) works[work_type].append(artist)
@@ -56,15 +66,16 @@ class Comic(Base, BaseMixin):
class Volume(Base, BaseMixin): class Volume(Base, BaseMixin):
__tablename__ = "volume" __tablename__ = "volume"
name = Column(String(length=255), nullable=False) name = Column(String, nullable=False)
comic_id = Column(String, ForeignKey("comic.id"), nullable=False) comic_id = Column(String, ForeignKey("comic.id"), nullable=False)
comic = relationship("Comic", back_populates="volumes") comic = relationship("Comic", back_populates="volumes")
story_arcs = relationship("StoryArc")
issues = relationship("Issue") issues = relationship("Issue")
class TradePaperback(Base, BaseMixin): class TradePaperback(Base, BaseMixin):
__tablename__ = "trade_paperback" __tablename__ = "trade_paperback"
name = Column(String(length=255), nullable=False) name = Column(String, nullable=False)
issue_start = Column(Integer) issue_start = Column(Integer)
issue_end = Column(Integer) issue_end = Column(Integer)
comic_id = Column(String, ForeignKey("comic.id"), nullable=False) comic_id = Column(String, ForeignKey("comic.id"), nullable=False)
@@ -73,31 +84,58 @@ class TradePaperback(Base, BaseMixin):
class StoryArc(Base, BaseMixin): class StoryArc(Base, BaseMixin):
__tablename__ = "story_arc" __tablename__ = "story_arc"
name = Column(String(length=255), nullable=False) name = Column(String, nullable=False)
comic_id = Column(String, ForeignKey("comic.id"), nullable=False) comic_id = Column(String, ForeignKey("comic.id"), nullable=False)
comic = relationship("Comic", back_populates="story_arcs") comic = relationship("Comic", back_populates="story_arcs")
volume_id = Column(String, ForeignKey("volume.id"), nullable=True)
volume = relationship("Volume", back_populates="story_arcs")
issues = relationship("Issue")
class Issue(Base, BaseMixin): class Issue(Base, BaseMixin):
__tablename__ = "issue" __tablename__ = "issue"
issue_number = Column(String(255)) issue_number = Column(String)
in_stock = Column(BIT(1)) title = Column(String, nullable=True)
is_read = Column(BIT(1)) published_on: Mapped[datetime] = mapped_column(nullable=True)
in_stock = Column(Boolean)
is_read = Column(Boolean)
comic_id = Column(String, ForeignKey("comic.id"), nullable=False) comic_id = Column(String, ForeignKey("comic.id"), nullable=False)
comic = relationship("Comic", back_populates="issues") comic = relationship("Comic", back_populates="issues")
volume_id = Column(String, ForeignKey("volume.id"), nullable=True) volume_id = Column(String, ForeignKey("volume.id"), nullable=True)
volume = relationship("Volume", back_populates="issues") volume = relationship("Volume", back_populates="issues")
story_arc_id = Column(String, ForeignKey("story_arc.id"), nullable=True)
story_arc = relationship("StoryArc", back_populates="issues")
issue_works = relationship("IssueWork")
def get_full_title(self) -> AnyStr:
full_title: AnyStr = self.issue_number
if self.title:
full_title += ": " + self.title
return full_title
def get_artists(self) -> Dict[Any, List[Any]]:
works: Dict[Any, List[Any]] = {}
for work in self.issue_works:
work_type = work.work_type
artist = work.artist
if work_type in works:
works[work_type].append(artist)
else:
works[work_type] = [artist]
return works
class Artist(Base, BaseMixin): class Artist(Base, BaseMixin):
__tablename__ = "artist" __tablename__ = "artist"
name = Column(String(length=255), nullable=False) name = Column(String, nullable=False)
weblink = Column(String, nullable=True)
comic_works = relationship("ComicWork") comic_works = relationship("ComicWork")
issue_works = relationship("IssueWork")
def get_comics(self) -> Dict[str, List[str]]: def get_comics(self) -> Dict[Any, List[Comic]]:
works: Dict[str, List[str]] = {} works: Dict[Any, List[Comic]] = {}
for work in self.comic_works: for work in self.comic_works:
work_type = work.work_type.name work_type = work.work_type
comic = work.comic comic = work.comic
if work_type in works: if work_type in works:
works[work_type].append(comic) works[work_type].append(comic)
@@ -108,8 +146,20 @@ class Artist(Base, BaseMixin):
class WorkType(Base, BaseMixin): class WorkType(Base, BaseMixin):
__tablename__ = "worktype" __tablename__ = "worktype"
name = Column(String(length=255), nullable=False, unique=True) name = Column(String, nullable=False, unique=True)
comic_works = relationship("ComicWork") comic_works = relationship("ComicWork")
issue_works = relationship("IssueWork")
def get_artists(self) -> Dict[str, List[str]]:
works: Dict[str, List[str]] = {}
for work in self.comic_works:
comic = work.comic.title
artist = work.artist
if comic in works:
works[comic].append(artist)
else:
works[comic] = [artist]
return works
def __repr__(self): def __repr__(self):
return f'Worktype({self.id} {self.version} {self.name} {len(self.comic_works)})' return f'Worktype({self.id} {self.version} {self.name} {len(self.comic_works)})'
@@ -126,3 +176,14 @@ class ComicWork(Base, BaseMixin):
artist = relationship("Artist", back_populates="comic_works") artist = relationship("Artist", back_populates="comic_works")
work_type_id = Column(String, ForeignKey("worktype.id"), nullable=False) work_type_id = Column(String, ForeignKey("worktype.id"), nullable=False)
work_type = relationship("WorkType", back_populates="comic_works") work_type = relationship("WorkType", back_populates="comic_works")
class IssueWork(Base, BaseMixin):
__tablename__ = "issue_work"
issue_id = Column(String, ForeignKey("issue.id"), nullable=False)
issue = relationship("Issue", back_populates="issue_works")
artist_id = Column(String, ForeignKey("artist.id"), nullable=False)
artist = relationship("Artist", back_populates="issue_works")
work_type_id = Column(String, ForeignKey("worktype.id"), nullable=False)
work_type = relationship("WorkType", back_populates="issue_works")
+6 -7
View File
@@ -1,10 +1,9 @@
import json import json
import logging import logging
import uuid
from datetime import datetime from datetime import datetime
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, List
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -13,7 +12,7 @@ from sqlalchemy.orm import sessionmaker
from src.db.models.tysc import Card, CardSet, Rooster, Team, FieldPosition, Player, Vendor, Sport from src.db.models.tysc import Card, CardSet, Rooster, Team, FieldPosition, Player, Vendor, Sport
from src.db.models.comic import Issue, TradePaperback, StoryArc, Volume, ComicWork, Artist, Comic, Publisher, WorkType from src.db.models.comic import Issue, TradePaperback, StoryArc, Volume, ComicWork, Artist, Comic, Publisher, WorkType
from src.db.models.bookshelf import ArticleAuthor, BookAuthor, BookshelfPublisher, Article, Book, Author from src.db.models.bookshelf import ArticleAuthor, BookAuthor, BookshelfPublisher, Article, Book, Author
from src.db.models.admin import Mail, MailAccount, ModuleData, Role, User, Token, AuthorizationMatrix from src.db.models.admin import Mail, MailAccount, ModuleData, Token, Assignment, Permission, Profile
from src.db.models.metadata import MetaDataTable, MetaDataColumn from src.db.models.metadata import MetaDataTable, MetaDataColumn
from src.db.models.media import MediaVideo, MediaArticle, MediaFile, MediaActor, MediaActorFile from src.db.models.media import MediaVideo, MediaArticle, MediaFile, MediaActor, MediaActorFile
@@ -79,10 +78,10 @@ class KontorDB:
self.registry[MediaVideo.__tablename__] = MediaVideo self.registry[MediaVideo.__tablename__] = MediaVideo
self.registry[MetaDataColumn.__tablename__] = MetaDataColumn self.registry[MetaDataColumn.__tablename__] = MetaDataColumn
self.registry[MetaDataTable.__tablename__] = MetaDataTable self.registry[MetaDataTable.__tablename__] = MetaDataTable
self.registry[AuthorizationMatrix.__tablename__] = AuthorizationMatrix self.registry[Assignment.__tablename__] = Assignment
self.registry[Token.__tablename__] = Token self.registry[Token.__tablename__] = Token
self.registry[User.__tablename__] = User self.registry[Profile.__tablename__] = Profile
self.registry[Role.__tablename__] = Role self.registry[Permission.__tablename__] = Permission
self.registry[ModuleData.__tablename__] = ModuleData self.registry[ModuleData.__tablename__] = ModuleData
self.registry[MailAccount.__tablename__] = MailAccount self.registry[MailAccount.__tablename__] = MailAccount
self.registry[Mail.__tablename__] = Mail self.registry[Mail.__tablename__] = Mail
@@ -360,7 +359,7 @@ class KontorDB:
update_list[link.id] = link.title update_list[link.id] = link.title
return update_list return update_list
def get_download_list(self) -> list[uuid.UUID]: def get_download_list(self) -> List[str]:
download_list = [] download_list = []
__session__ = sessionmaker(self.engine) __session__ = sessionmaker(self.engine)
_filter = { 'should_download': True} _filter = { 'should_download': True}
+30 -20
View File
@@ -6,8 +6,7 @@ from pathlib import Path
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from sqlalchemy import Column, String, ForeignKey from sqlalchemy import Column, String, ForeignKey, Boolean
from sqlalchemy.dialects.mysql import BIT
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.db.models.base import Base, BaseMixin, BaseVideoMixin from src.db.models.base import Base, BaseMixin, BaseVideoMixin
@@ -30,10 +29,10 @@ class MediaFile(Base, BaseMixin, BaseVideoMixin):
soup = BeautifulSoup(r.content, "html.parser") soup = BeautifulSoup(r.content, "html.parser")
title = soup.title.string title = soup.title.string
self.title = title self.title = title
self.review = 0 self.review = False
except: except:
self.title = None self.title = None
self.review = 1 self.review = True
self.last_modified_date = datetime.now() self.last_modified_date = datetime.now()
def download_file(self, download_dir: str, dl_tool: str): def download_file(self, download_dir: str, dl_tool: str):
@@ -45,12 +44,12 @@ class MediaFile(Base, BaseMixin, BaseVideoMixin):
lines_list = output.splitlines() lines_list = output.splitlines()
file_name = self.__parse_output__(lines_list) file_name = self.__parse_output__(lines_list)
if file_name is None: if file_name is None:
self.review = 1 self.review = True
self.should_download = 1 self.should_download = True
self.file_name = None self.file_name = None
else: else:
download_file = Path(file_name) download_file = Path(file_name)
self.should_download = 0 self.should_download = False
self.file_name = download_file.name self.file_name = download_file.name
self.cloud_link = str(download_file.absolute()) self.cloud_link = str(download_file.absolute())
self.last_modified_date = datetime.now() self.last_modified_date = datetime.now()
@@ -71,31 +70,42 @@ class MediaFile(Base, BaseMixin, BaseVideoMixin):
class MediaActor(Base, BaseMixin): class MediaActor(Base, BaseMixin):
__tablename__ = 'media_actor' __tablename__ = 'media_actor'
name = Column(String(255)) name = Column(String)
media_actor_files = relationship("MediaActorFile") media_actor_files = relationship("MediaActorFile")
class MediaActorFile(Base, BaseMixin): class MediaActorFile(Base, BaseMixin):
__tablename__ = 'media_actor_file' __tablename__ = 'media_actor_file'
media_actor_id = Column(String(255), ForeignKey("media_actor.id"), nullable=False) media_actor_id = Column(String, ForeignKey("media_actor.id"), nullable=False)
media_actor = relationship("MediaActor", back_populates="media_actor_files") media_actor = relationship("MediaActor", back_populates="media_actor_files")
media_file_id = Column(String(255), ForeignKey("media_file.id"), nullable=True) media_file_id = Column(String, ForeignKey("media_file.id"), nullable=True)
media_file = relationship("MediaFile", back_populates="media_actor_files") media_file = relationship("MediaFile", back_populates="media_actor_files")
class MediaArticle(Base, BaseMixin): class MediaArticle(Base, BaseMixin):
__tablename__ = 'media_article' __tablename__ = 'media_article'
review = Column(BIT(1)) review = Column(Boolean)
title = Column(String(255)) title = Column(String)
url = Column(String(255), unique=True) url = Column(String, unique=True)
class MediaVideo(Base, BaseMixin): class MediaVideo(Base, BaseMixin):
__tablename__ = 'media_video' __tablename__ = 'media_video'
cloud_link = Column(String(255)) cloud_link = Column(String)
file_name = Column(String(255)) file_name = Column(String)
path = Column(String(255)) path = Column(String)
review = Column(BIT(1)) review = Column(Boolean)
title = Column(String(255)) title = Column(String)
url = Column(String(255), unique=True) url = Column(String, unique=True)
should_download = Column(BIT(1)) should_download = Column(Boolean)
def __repr__(self):
return f'MediaFile({self.id} {self.title} {self.url})'
def __str__(self):
if self.title is None:
return f'{self.url}({self.id})'
else:
return f'{self.title}({self.id})'
-42
View File
@@ -1,42 +0,0 @@
from sqlalchemy import Column, String, ForeignKey, Integer
from sqlalchemy.dialects.mysql import BIT
from sqlalchemy.orm import relationship
from src.db.models.base import Base, BaseMixin
class MetaDataTable(Base, BaseMixin):
__tablename__ = 'meta_data_table'
table_name = Column(String(255), unique=True)
table_columns = relationship("MetaDataColumn")
def __repr__(self):
return f'MetaDataTable({self.id} {self.table_name})'
def __str__(self):
return f'{self.table_name}({self.id})'
class MetaDataColumn(Base, BaseMixin):
__tablename__ = 'meta_data_column'
column_name = Column(String(255), nullable=False)
column_sync_name = Column(String(255))
column_type = Column(String(255))
column_modifier = Column(String(255), nullable=True)
column_order = Column(Integer)
table_id = Column(String, ForeignKey('meta_data_table.id'))
table = relationship("MetaDataTable", back_populates="table_columns")
column_label = Column(String(255))
filter_label = Column(String(255))
is_shown = Column(BIT(1))
show_filter = Column(BIT(1))
ref_column = Column(String, nullable=True)
def __repr__(self):
if self.column_name is None:
return f'MetaDataColumn({self.id} {self.table.table_name}.__)'
else:
return f'MetaDataColumn({self.id} {self.table.table_name}.{self.column_name})'
def __str__(self):
return f'{self.column_name}({self.id})'
+12 -13
View File
@@ -1,5 +1,4 @@
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, Boolean
from sqlalchemy.dialects.mysql import BIT
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.db.models.base import Base, BaseMixin from src.db.models.base import Base, BaseMixin
@@ -10,15 +9,15 @@ class Sport(Base, BaseMixin):
__table_args__ = ( __table_args__ = (
UniqueConstraint("name"), UniqueConstraint("name"),
) )
name = Column(String(255), nullable=False, index=True, unique=True) name = Column(String, nullable=False, index=True, unique=True)
teams = relationship("Team") teams = relationship("Team")
positions = relationship("FieldPosition") positions = relationship("FieldPosition")
class Team(Base, BaseMixin): class Team(Base, BaseMixin):
__tablename__ = "team" __tablename__ = "team"
name = Column(String(255), nullable=False, index=True, unique=True) name = Column(String, nullable=False, index=True, unique=True)
short_name = Column(String(255), nullable=False, ) short_name = Column(String, nullable=False, )
sport_id = Column(String, ForeignKey("sport.id"), nullable=False) sport_id = Column(String, ForeignKey("sport.id"), nullable=False)
sport = relationship("Sport", back_populates="teams") sport = relationship("Sport", back_populates="teams")
roosters = relationship("Rooster") roosters = relationship("Rooster")
@@ -30,8 +29,8 @@ class FieldPosition(Base, BaseMixin):
UniqueConstraint("name", "sport_id"), UniqueConstraint("name", "sport_id"),
UniqueConstraint("short_name", "sport_id"), UniqueConstraint("short_name", "sport_id"),
) )
name = Column(String(255), nullable=False, index=True) name = Column(String, nullable=False, index=True)
short_name = Column(String(255), nullable=False) short_name = Column(String, nullable=False)
sport_id = Column(String, ForeignKey("sport.id"), nullable=False, index=True) sport_id = Column(String, ForeignKey("sport.id"), nullable=False, index=True)
sport = relationship("Sport", back_populates="positions") sport = relationship("Sport", back_populates="positions")
roosters = relationship("Rooster") roosters = relationship("Rooster")
@@ -42,8 +41,8 @@ class Player(Base, BaseMixin):
__table_args__ = ( __table_args__ = (
UniqueConstraint("first_name", "last_name"), UniqueConstraint("first_name", "last_name"),
) )
first_name = Column(String(255), nullable=False, index=True) first_name = Column(String, nullable=False, index=True)
last_name = Column(String(255), nullable=False, index=True) last_name = Column(String, nullable=False, index=True)
roosters = relationship("Rooster") roosters = relationship("Rooster")
def get_full_name(self) -> str: def get_full_name(self) -> str:
@@ -67,7 +66,7 @@ class Rooster(Base, BaseMixin):
class Vendor(Base, BaseMixin): class Vendor(Base, BaseMixin):
__tablename__ = "vendor" __tablename__ = "vendor"
name = Column(String(255), nullable=False, unique=True, index=True) name = Column(String, nullable=False, unique=True, index=True)
card_sets = relationship("CardSet") card_sets = relationship("CardSet")
cards = relationship("Card") cards = relationship("Card")
@@ -77,9 +76,9 @@ class CardSet(Base, BaseMixin):
__table_args__ = ( __table_args__ = (
UniqueConstraint("name", "vendor_id"), UniqueConstraint("name", "vendor_id"),
) )
name = Column(String(255), index=True) name = Column(String, index=True)
parallel_set = Column(BIT(1)) parallel_set = Column(Boolean)
insert_set = Column(BIT(1)) insert_set = Column(Boolean)
vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False, index=True) vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False, index=True)
vendor = relationship("Vendor", back_populates="card_sets") vendor = relationship("Vendor", back_populates="card_sets")
cards = relationship("Card") cards = relationship("Card")
+10
View File
@@ -0,0 +1,10 @@
from typing import AnyStr
from sqlalchemy.orm import Session
from src.db.models.admin import Profile
def get_profile(username: AnyStr, db: Session):
profile = db.query(Profile).filter(Profile.email == username).first()
return profile
@@ -0,0 +1,19 @@
from src.db.models.comic import Artist
from src.schema.comics.artist import ArtistDetailResponse
def get_artist_details(artist: Artist) -> ArtistDetailResponse:
works = {}
for work in artist.comic_works:
work_type = work.work_type.name
comic_title = work.comic.title
if work_type in works:
works[work_type].append(comic_title)
else:
works[work_type] = [comic_title]
response = ArtistDetailResponse(
id=artist.id,
name=artist.name,
works=works
)
return response
@@ -0,0 +1,31 @@
from typing import List, Type, AnyStr
from sqlalchemy.orm import Session
from src.core.log_conf import logger
from src.db.models.comic import Comic, Issue
from src.schema.comics.comic import ComicSchema
from src.schema.comics.issue import IssueDetailsResponse
def list_comics(db: Session) -> List[Type[Comic]]:
comics = db.query(Comic).all()
return comics
def get_issue_details(issue: Issue) -> IssueDetailsResponse:
response = IssueDetailsResponse(
id=issue.id,
issue_number=issue.issue_number,
in_stock=issue.in_stock,
is_read=issue.is_read,
comic_id=issue.comic_id,
volume_id=issue.volume_id
)
return response
def update_comic(comic: ComicSchema, comic_id: AnyStr, db: Session) -> type[Comic] | None:
logger.info(f"update_comic: {comic} with {comic_id}")
comic = db.get(Comic, comic_id)
return comic
@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from typing import AnyStr
from sqlalchemy.orm import Session
from src.core.log_conf import logger
from src.db.models.comic import WorkType
from src.schema.comics.worktype import AddWorkType
def create_new_worktype(work: AddWorkType, db: Session) -> WorkType:
worktype = WorkType()
worktype.id = str(uuid.uuid4())
worktype.created_date = datetime.now()
worktype.last_modified_date = datetime.now()
worktype.name = work.worktype
db.add(worktype)
db.commit()
db.refresh(worktype)
logger.info(f"create_new_worktype: {worktype}")
return worktype
def update_worktype(work: AddWorkType, worktype_id: AnyStr, db: Session) -> WorkType:
logger.info("update worktype")
worktype = db.get(WorkType, worktype_id)
worktype.name = work.worktype
db.add(worktype)
db.commit()
db.refresh(worktype)
return worktype
+40
View File
@@ -0,0 +1,40 @@
from sqlalchemy.orm import Session
from typing import AnyStr
import uuid
from datetime import datetime
from src.core.log_conf import logger
from src.db.models.media import MediaFile, MediaVideo
from src.webapps.media.forms import AddLinkForm
def create_new_video(video: AddLinkForm, db: Session) -> MediaVideo:
print(video.url)
media_video = MediaVideo()
media_video.id = str(uuid.uuid4())
media_video.url = video.url
media_video.created_date = datetime.now()
media_video.last_modified_date = datetime.now()
media_video.review = True
media_video.should_download = True
db.add(media_video)
db.commit()
db.refresh(media_video)
print(media_video)
return media_video
def create_new_mediafile(link: AnyStr, db: Session) -> MediaFile:
logger.info("create MediaFile with url {link}")
media_file: MediaFile = MediaFile()
media_file.id = str(uuid.uuid4())
media_file.url = link
media_file.created_date = datetime.now()
media_file.last_modified_date = datetime.now()
media_file.version = 0
media_file.review = True
media_file.should_download = True
db.add(media_file)
db.commit()
db.refresh(media_file)
logger.info(f"created {media_file}")
return media_file
+2 -2
View File
@@ -1,7 +1,7 @@
from typing import Generator, Annotated from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker
from src.core.config import settings from src.core.config import settings
+28
View File
@@ -0,0 +1,28 @@
import databases
from src.db.session import SQLALCHEMY_DATABASE_URL
async def check_db_connected():
try:
if not str(SQLALCHEMY_DATABASE_URL).__contains__("sqlite"):
database = databases.Database(SQLALCHEMY_DATABASE_URL)
if not database.is_connected:
await database.connect()
await database.execute("SELECT 1")
print("Database is connected (^_^)")
except Exception as e:
print(
"Looks like db is missing or is there is some problem in connection,see below traceback"
)
raise e
async def check_db_disconnected():
try:
if not str(SQLALCHEMY_DATABASE_URL).__contains__("sqlite"):
database = databases.Database(SQLALCHEMY_DATABASE_URL)
if database.is_connected:
await database.disconnect()
print("Database is Disconnected (-_-) zZZ")
except Exception as e:
raise e
+17 -5
View File
@@ -1,12 +1,25 @@
import logging
import logging.config
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from src.apis.base import api_router from src.apis.base import api_router
from src.core.log_conf import LOGGING_CONFIG, logger
from src.db.session import engine from src.db.session import engine
from src.db.utils import check_db_connected, check_db_disconnected
from src.webapps.base import api_router as web_app_router from src.webapps.base import api_router as web_app_router
from src.core.config import settings from src.core.config import settings
from src.db.models.base import Base from src.db.models.base import Base
@asynccontextmanager
async def lifespan(app: FastAPI):
await check_db_connected()
yield
await check_db_disconnected()
def include_router(app: FastAPI): def include_router(app: FastAPI):
app.include_router(api_router) app.include_router(api_router)
app.include_router(web_app_router) app.include_router(web_app_router)
@@ -17,13 +30,12 @@ def configure_static(app: FastAPI):
def create_tables(): def create_tables():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
def start_application(): def start_application(log):
app = FastAPI(title=settings.PROJECT_NAME, version=settings.PROJECT_VERSION) log.info(f"using database: {settings.DATABASE_URL}")
app = FastAPI(title=settings.PROJECT_NAME, version=settings.PROJECT_VERSION, lifespan=lifespan)
include_router(app) include_router(app)
configure_static(app) configure_static(app)
create_tables() create_tables()
return app return app
kontor = start_application(logger)
kontor = start_application()
+8
View File
@@ -0,0 +1,8 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
+4 -21
View File
@@ -1,36 +1,19 @@
from typing import List, Dict from typing import List, Dict
from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from src.db.models.comic import Artist
class ArtistCreation(BaseModel): class ArtistCreation(BaseModel):
id: str
name: str name: str
class ArtistResponse(BaseModel): class ArtistResponse(BaseModel):
id: UUID id: str
name: str name: str
class ArtistDetailResponse(BaseModel): class ArtistDetailResponse(BaseModel):
id: UUID id: str
name: str name: str
weblink: str
works: Dict[str, List[str]] works: Dict[str, List[str]]
def get_artist_details(artist: Artist) -> ArtistDetailResponse:
works = {}
for work in artist.comic_works:
work_type = work.work_type.name
comic_title = work.comic.title
if work_type in works:
works[work_type].append(comic_title)
else:
works[work_type] = [comic_title]
response = ArtistDetailResponse(
id=artist.id,
name=artist.name,
works=works
)
return response
+17 -8
View File
@@ -1,26 +1,35 @@
from typing import List, Dict from typing import List, Dict, Optional
from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel, AnyUrl
from src.db.models.comic import Comic from src.db.models.comic import Comic
class ComicResponse(BaseModel): class ComicResponse(BaseModel):
id: UUID id: str
title: str title: str
completed: bool completed: bool
class ComicDetailsResponse(BaseModel): class ComicDetailsResponse(BaseModel):
id: UUID id: str
created: str created: str
title: str title: str
completed : bool completed : bool
current_order : bool current_order : bool
weblink: str
publisher: str publisher: str
volumes: List[str] volumes: List[str]
works: Dict[str, List[str]] works: Dict[str, List[str]]
class ComicSchema(BaseModel):
id: str
title: str
weblink: Optional[AnyUrl]
completed: Optional[bool]
current_order: Optional[bool]
def get_short_info(comic: Comic) -> ComicResponse: def get_short_info(comic: Comic) -> ComicResponse:
response = ComicResponse( response = ComicResponse(
id=comic.id, id=comic.id,
@@ -46,11 +55,11 @@ def get_comic_details(comic: Comic) -> ComicDetailsResponse | None:
id=comic.id, id=comic.id,
created=str(comic.created_date), created=str(comic.created_date),
title=comic.title, title=comic.title,
completed=(comic.completed == 1), completed=comic.completed,
current_order=(comic.current_order == 1), current_order=comic.current_order,
weblink=comic.weblink,
publisher=comic.publisher.name, publisher=comic.publisher.name,
volumes=volumes, volumes=volumes,
works=works works=works
) )
return response return response
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class IssueDetailsResponse(BaseModel):
id: str
issue_number: str
in_stock: bool
is_read: bool
comic_id: str
volume_id: str | None
+4
View File
@@ -0,0 +1,4 @@
from pydantic import BaseModel
class AddWorkType(BaseModel):
worktype: str
+5 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime from datetime import datetime
from uuid import UUID
from src.db.models.media import MediaFile from src.db.models.media import MediaFile
from pydantic import BaseModel from pydantic import BaseModel
class MediaFileResponse(BaseModel): class MediaFileResponse(BaseModel):
id: UUID id: str
title: str | None = None title: str | None = None
file_name: str | None = None file_name: str | None = None
cloud_link: str | None = None cloud_link: str | None = None
@@ -23,8 +22,8 @@ def get_file_details(mediafile: MediaFile) -> MediaFileResponse | None:
file_name=mediafile.file_name, file_name=mediafile.file_name,
cloud_link=mediafile.cloud_link, cloud_link=mediafile.cloud_link,
url=str(mediafile.url), url=str(mediafile.url),
review=(mediafile.review == 1), review=mediafile.review,
should_download=(mediafile.should_download == 1)) should_download=mediafile.should_download)
#print(f"id: {mediafile.id}: review: {response.review} <- {mediafile.review}") #print(f"id: {mediafile.id}: review: {response.review} <- {mediafile.review}")
#print(f"id: {mediafile.id}: download: {response.should_download} <- {mediafile.should_download}") #print(f"id: {mediafile.id}: download: {response.should_download} <- {mediafile.should_download}")
return response return response
@@ -35,11 +34,5 @@ def set_file(model: MediaFileResponse, mediafile: MediaFile) -> None:
mediafile.url = model.url mediafile.url = model.url
mediafile.title = model.title mediafile.title = model.title
mediafile.last_modified_date = datetime.now() mediafile.last_modified_date = datetime.now()
if model.review: mediafile.review = model.review
mediafile.review = 1 mediafile.should_download = model.should_download
else:
mediafile.review = 0
if model.should_download:
mediafile.should_download = 1
else:
mediafile.should_download = 0
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class AddLink(BaseModel):
url: str
+2 -2
View File
@@ -1,8 +1,8 @@
from uuid import UUID
from typing import AnyStr
from pydantic import BaseModel from pydantic import BaseModel
class SportResponse(BaseModel): class SportResponse(BaseModel):
id: UUID id: AnyStr
name: str name: str
@@ -0,0 +1,38 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Permissions</title>
{% endblock %}
{% block content %}
{% with msg=msg %}
{% include "components/alerts.html" %}
{% endwith %}
<div class="container">
<div class="row">
<div class="col">
<h1 class="display-5">Permissions..</h1>
</div>
</div>
<div class="row">
<table class="table table-hover">
<thead><tr>
<th scope="col">Name</th>
<th colspan="2">Actions</th>
</tr></thead>
<tbody>
{% for permission in permissions %}
<tr>
<th scope="row"><a href="/admins/permissions/{{permission.id}}">{{permission.name}}</a></th>
<td><a href="/admin/permission/edit/{{permission.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a></td>
<td><a href="/admin/permission/delete/{{permission.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<div>
<a href="/admin/permission/add" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Add Permission</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Profiles</title>
{% endblock %}
{% block content %}
{% with msg=msg %}
{% include "components/alerts.html" %}
{% endwith %}
<div class="container">
<div class="row">
<div class="col">
<h1 class="display-5">Profiles..</h1>
</div>
</div>
<div class="row">
<table class="table table-hover">
<thead><tr>
<th scope="col">Username</th>
<th scope="col">First Name</th>
<th scope="col">Last Name</th>
<th colspan="2">Actions</th>
</tr></thead>
<tbody>
{% for profile in profiles %}
<tr>
<th scope="row"><a href="/admins/profiles/{{profile.id}}">{{profile.user_name}}</a></th>
<th scope="row">{{profile.first_name}}</th>
<th scope="row">{{profile.last_name}}</th>
<th scope="row">{{profile.email}}</th>
<td><a href="/admin/profile/edit/{{profile.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a></td>
<td><a href="/admin/profile/delete/{{profile.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<div>
<a href="/admin/profile/add" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Add Profile</a>
</div>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Login</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<h5 class="display-5">Login to Kontor</h5>
<div class="text-danger font-weight-bold">
{% for error in errors %}
<li>{{error}}</li>
{% endfor %}
</div>
<div class="text-success font-weight-bold">
{% if msg %}
<div class="badge bg-success text-wrap font-weight-bold" style="font-size: large;">
{{msg}}
</div>
{% endif %}
</div>
</div>
<div class="row my-5">
<form method="POST">
<div class="mb-3">
<label>Email</label>
<input type="text" required placeholder="Your email" name="email" value="{{email}}" class="form-control">
</div>
<div class="mb-3">
<label>Password</label>
<input type="password" required placeholder="Choose a secure password" value="{{password}}" name="password" class="form-control">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
{% endblock %}
@@ -1,6 +1,7 @@
<div class="card shadow p-3 mb-2 bg-body rounded" style="width: 18rem;"> <div class="card shadow p-3 mb-2 bg-body rounded" style="width: 18rem;">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{obj.name}}</h5> <h5 class="card-title">{{obj.name}}</h5>
<p class="card-text">Link : {{obj.weblink}}</p>
<a href="/comic/artists/{{obj.id}}" class="btn btn-primary">Read more</a> <a href="/comic/artists/{{obj.id}}" class="btn btn-primary">Read more</a>
</div> </div>
</div> </div>
@@ -20,12 +20,16 @@
<th scope="row">Artist Name</th> <th scope="row">Artist Name</th>
<td colspan="2">{{artist.name}}</td> <td colspan="2">{{artist.name}}</td>
</tr> </tr>
<tr>
<th scope="row">Link</th>
<td colspan="2">{{artist.weblink}}</td>
</tr>
<tr> <tr>
<th scope="row">Works</th> <th scope="row">Works</th>
<td colspan="2"> <td colspan="2">
{% for work in artist.get_comics() %} {% for work in artist.get_comics() %}
<p> <p>
{{work}}: <a href="/comic/worktypes/{{work.id}}">{{work.name}}</a>
<ul> <ul>
{% for comic in artist.get_comics()[work] %} {% for comic in artist.get_comics()[work] %}
<li><a href="/comic/comics/{{comic.id}}">{{comic.title}}</a></li> <li><a href="/comic/comics/{{comic.id}}">{{comic.title}}</a></li>
@@ -43,8 +47,20 @@
<th scope="row">Data Modified</th> <th scope="row">Data Modified</th>
<td colspan="2">{{artist.last_modified_date}}</td> <td colspan="2">{{artist.last_modified_date}}</td>
</tr> </tr>
<tr>
<th scope="row">Data Version</th>
<td colspan="2">{{artist.version}}</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="row">
<div>
<a href="/comic/artists" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Back to list</a>
<a href="/comic/artist/edit/{{artist.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a>
<a href="/comic/artist/delete/{{artist.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,32 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Edit Artist</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="text-danger font-weight-bold">
{% for error in errors %}
<li>{{error}}</li>
{% endfor %}
</div>
</div>
<div class="row my-5">
<h3 class="text-center display-4">Edit an Artist entry</h3>
<form method="POST">
<div class="mb-3">
<input type="text" required class="form-control" name="artist.name" value="{{artist_name}}" placeholder="Artist name here">
<input type="text" required class="form-control" name="artist.link" value="{{artist_link}}" placeholder="Web link for artist here">
</div>
<div>
<button type="submit" class="btn btn-primary">Submit</button>
<button type="cancel" class="btn btn-primary">Cancel</button>
</div>
</form>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -18,7 +18,7 @@
{% for artist in artists %} {% for artist in artists %}
<div class="col-lg-4 col-md-3 col-sm-10 mr-auto"> <div class="col-lg-4 col-md-3 col-sm-10 mr-auto">
{% with obj=artist %} {% with obj=artist %}
{% include "components/artist_cards.html" %} {% include "comic/artist_cards.html" %}
{% endwith %} {% endwith %}
{% if loop.index %3 %} {% if loop.index %3 %}
@@ -27,12 +27,17 @@
{% endwith %} {% endwith %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Link</th>
<td colspan="2">{{comic.weblink}}</td>
</tr>
{% if comic.get_artists()|length > 0 %}
<tr> <tr>
<th scope="row">Works</th> <th scope="row">Works</th>
<td colspan="2"> <td colspan="2">
{% for work in comic.get_artists() %} {% for work in comic.get_artists() %}
<p> <p>
{{work}}: <a href="/comic/worktypes/{{work.id}}">{{work.name}}</a>
<ul> <ul>
{% for artist in comic.get_artists()[work] %} {% for artist in comic.get_artists()[work] %}
<li><a href="/comic/artists/{{artist.id}}">{{artist.name}}</a></li> <li><a href="/comic/artists/{{artist.id}}">{{artist.name}}</a></li>
@@ -42,6 +47,29 @@
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
{% endif %}
{% if comic.volumes|length > 0 %}
<tr>
<th scope="row">Volumes</th>
<td colspan="2">
<ul>
{% for volume in comic.volumes %}
<li><a href="/comic/volumes/{{volume.id}}">{{volume.name}}</a></li>
{% endfor %}
</ul>
</td>
</tr>
{% endif %}
<tr>
<th scope="row">Issues</th>
<td colspan="2">
<ul>
{% for issue in comic.sorted_issues() %}
<li><a href="/comic/issues/{{issue.id}}">{{issue.get_full_title()}}</a></li>
{% endfor %}
</ul>
</td>
</tr>
<tr> <tr>
<th scope="row">Data Created</th> <th scope="row">Data Created</th>
<td colspan="2">{{comic.created_date}}</td> <td colspan="2">{{comic.created_date}}</td>
@@ -54,18 +82,15 @@
<th scope="row">Data Version</th> <th scope="row">Data Version</th>
<td colspan="2">{{comic.version}}</td> <td colspan="2">{{comic.version}}</td>
</tr> </tr>
<tr>
<th scope="row">Issues</th>
<td colspan="2">
<ul>
{% for issue in comic.sorted_issues() %}
<li><a href="comic/issues/{{issue.id}}">{{issue.issue_number}}</a></li>
{% endfor %}
</ul>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="row">
<div>
<a href="/comic/comics" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Back to list</a>
<a href="/comic/comic/edit/{{comic.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a>
<a href="/comic/comic/delete/{{comic.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,56 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Edit Comic</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="text-danger font-weight-bold">
{% for error in errors %}
<li>{{error}}</li>
{% endfor %}
</div>
</div>
<div class="row my-5">
<h3 class="text-center display-4">Edit an Comic entry</h3>
<form class="form-horizontal" method="POST">
<div class="form-group">
<label class="control-label col-sm-2" for="title">Title</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="title" name="title" value="{{comic_title}}" placeholder="Comic title here">
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2" for="weblink">Link</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="weblink" name="weblink" value="{{comic_weblink}}" placeholder="Web link for comic here">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label><input type="checkbox" id="completed" name="completed" value="{{comic_completed}}"> Completed</label>
</div
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label><input type="checkbox" id="current_order" name="current_order" value="{{comic_current_order}}"> Current Order</label>
</div
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" name="action" value="submit">Submit</button>
<button type="cancel" class="btn btn-primary" name="action" value="cancel">Cancel</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
+34 -17
View File
@@ -9,23 +9,40 @@
{% include "components/alerts.html" %} {% include "components/alerts.html" %}
{% endwith %} {% endwith %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <form class="d-flex" action="/comic/comics/">
<h1 class="display-5">Find Jobs..</h1> <input class="form-control me-2" name="query" id="autocomplete" type="search" placeholder="Search" aria-label="Search">
Completed<input type="checkbox" name="completed" {% if request.query_params.get("completed")=="on" %}checked{% endif %} aria-label="Completed">
Order<input type="checkbox" name="order" {% if request.query_params.get("order")=="on" %}checked{% endif %} aria-label="Order">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
<div class="row">
<h1 class="display-5">Comics..</h1>
</div>
<div class="row">
<table class="table table-hover">
<thead><tr>
<th scope="col">Title</th>
<th scope="col">Publisher</th>
<th scope="col">Completed</th>
<th colspan="2">Actions</th>
</tr></thead>
<tbody>
{% for comic in comics %}
<tr>
<th scope="row"><a href="/comic/comics/{{comic.id}}">{{comic.title}}</a></th>
<td><a href="/comic/publishers/{{comic.publisher.id}}">{{comic.publisher.name}}</a></td>
<td>{% with check=comic.completed %}{% include "components/check.html" %}{% endwith %}</td>
<td><a href="/comic/comics/edit/{{comic.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a></td>
<td><a href="/comic/comics/delete/{{comic.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<div>
<a href="/comic/comic/add" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Add Comic</a>
</div>
</div> </div>
</div> </div>
<div class="row">
{% for comic in comics %}
<div class="col-lg-4 col-md-3 col-sm-10 mr-auto">
{% with obj=comic %}
{% include "components/comic_cards.html" %}
{% endwith %}
{% if loop.index %3 %}
</div>
{% else %}
</div></div><br><div class="row">
{% endif %}
{% endfor %}
</div>
</div>
{% endblock %} {% endblock %}
@@ -0,0 +1,92 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Issue Detail</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col">
<h1 class="display-5">Issue Detail</h1>
</div>
</div>
<div class="row">
<table class="table table-striped table-hover">
<tbody>
<tr>
<th scope="row">Issue Number</th>
<td colspan="2">{{issue.issue_number}}</td>
</tr>
<tr>
<th scope="row">Full Title</th>
<td colspan="2">{{issue.title}}</td>
</tr>
<tr>
<th scope="row">Published</th>
<td colspan="2">{{issue.published_on}}</td>
</tr>
<tr>
<th scope="row">Auf Lager</th>
<td colspan="2">
{% with check=issue.in_stock %}
{% include "components/check.html" %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Gelesen</th>
<td colspan="2">
{% with check=issue.is_read %}
{% include "components/check.html" %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Comic</th>
<td colspan="2">
<a href="/comic/comics/{{issue.comic_id}}">{{issue.comic.title}}</a>
</td>
</tr>
{% if issue.volume %}
<tr>
<th scope="row">Volume</th>
<td colspan="2">
<a href="/comic/comics/{{issue.volume_id}}">{{issue.volume.name}}</a>
</td>
</tr>
{% endif %}
<tr>
<th scope="row">Works</th>
<td colspan="2">
{% for work in issue.get_artists() %}
<p>
<a href="/comic/worktypes/{{work.id}}">{{work.name}}</a>
<ul>
{% for artist in issue.get_artists()[work] %}
<li><a href="/comic/artists/{{artist.id}}">{{artist.name}}</a></li>
{% endfor %}
</ul>
</p>
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">Data Created</th>
<td colspan="2">{{issue.created_date}}</td>
</tr>
<tr>
<th scope="row">Data Modified</th>
<td colspan="2">{{issue.last_modified_date}}</td>
</tr>
<tr>
<th scope="row">Data Version</th>
<td colspan="2">{{issue.version}}</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -20,6 +20,24 @@
<th scope="row">Publisher Name</th> <th scope="row">Publisher Name</th>
<td colspan="2">{{publisher.name}}</td> <td colspan="2">{{publisher.name}}</td>
</tr> </tr>
{% if publisher.parent_publisher_id %}
<tr>
<th scope="row">Parent Company</th>
<td colspan="2"><a href="/comic/publishers/{{publisher.parent_publisher_id}}">{{publisher.parent_publisher.name}}</a></td>
</tr>
{% endif %}
{% if publisher.imprints|length > 0 %}
<tr>
<th scope="row">Imprints</th>
<td colspan="2">
<ul>
{% for imprint in publisher.imprints %}
<li><a href="/comic/publishers/{{imprint.id}}">{{imprint.name}}</a></li>
{% endfor %}
</ul>
</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">Comics</th> <th scope="row">Comics</th>
<td colspan="2"> <td colspan="2">
@@ -41,5 +59,12 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="row">
<div>
<a href="/comic/publishers" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Back to list</a>
<a href="/comic/publisher/edit/{{publisher.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a>
<a href="/comic/publisher/delete/{{publisher.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -18,7 +18,7 @@
{% for publisher in publishers %} {% for publisher in publishers %}
<div class="col-lg-4 col-md-3 col-sm-10 mr-auto"> <div class="col-lg-4 col-md-3 col-sm-10 mr-auto">
{% with obj=publisher %} {% with obj=publisher %}
{% include "components/publisher_cards.html" %} {% include "comic/publisher_cards.html" %}
{% endwith %} {% endwith %}
{% if loop.index %3 %} {% if loop.index %3 %}
</div> </div>
@@ -0,0 +1,60 @@
{% extends "shared/base.html" %}
{% block title %}
<title>WorkType Detail</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<table class="table table-striped table-hover">
<tbody>
<tr>
<th scope="row">WorkType Name</th>
<td colspan="2">{{worktype.name}}</td>
</tr>
<tr>
<th scope="row">Works</th>
<td colspan="2">
{% for comic in worktype.get_artists() %}
<p>
{{comic}}:
<ul>
{% for artist in worktype.get_artists()[comic] %}
<li><a href="/comic/artists/{{artist.id}}">{{artist.name}}</a></li>
{% endfor %}
</ul>
</p>
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">ID</th>
<td colspan="2">{{worktype.id}}</td>
</tr>
<tr>
<th scope="row">Data Created</th>
<td colspan="2">{{worktype.created_date}}</td>
</tr>
<tr>
<th scope="row">Data Modified</th>
<td colspan="2">{{worktype.last_modified_date}}</td>
</tr>
<tr>
<th scope="row">Data Version</th>
<td colspan="2">{{worktype.version}}</td>
</tr>
</tbody>
</table>
</div>
<div class="row">
<div>
<a href="/comic/worktypes" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Back to list</a>
<a href="/comic/worktype/edit/{{worktype.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a>
<a href="/comic/worktype/delete/{{worktype.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Edit WorkType</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="text-danger font-weight-bold">
{% for error in errors %}
<li>{{error}}</li>
{% endfor %}
</div>
</div>
<div class="row my-5">
<h3 class="text-center display-4">Add a WorkType</h3>
<form method="POST">
<div class="mb-3">
<input type="text" required class="form-control" name="worktype" value="{{worktype}}" placeholder="WorkType here">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "shared/base.html" %}
{% block title %}
<title>WorkTypes</title>
{% endblock %}
{% block content %}
{% with msg=msg %}
{% include "components/alerts.html" %}
{% endwith %}
<div class="container">
<table class="table table-hover">
<thead><tr>
<th scope="col">Name</th>
</tr></thead>
<tbody>
{% for worktype in worktypes %}
<tr>
<th scope="row"><a href="/comic/worktypes/{{worktype.id}}">{{worktype.name}}</a></th>
<td><a href="/comic/worktype/edit/{{worktype.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a></td>
<td><a href="/comic/worktype/delete/{{worktype.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="/comic/worktype/add" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Add WorkType</a>
</div>
{% endblock %}
@@ -1,4 +1,4 @@
{% if check == 1 %} {% if check %}
<img src="{{ url_for('static', path='images/tick.png') }}" alt="" width="24" height="24"> <img src="{{ url_for('static', path='images/tick.png') }}" alt="" width="24" height="24">
{% else %} {% else %}
<img src="{{ url_for('static', path='images/cross.png') }}" alt="" width="24" height="24"> <img src="{{ url_for('static', path='images/cross.png') }}" alt="" width="24" height="24">
@@ -1,8 +1,6 @@
<nav class="navbar navbar-expand-lg navbar-light bg-light px-5"> <nav class="navbar navbar-expand-lg navbar-light bg-light px-5">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="#"> <a class="navbar-brand" href="#">Kontor</a>
<img src="{{ url_for('static', path='images/logo.png') }}" alt="" width="30" height="24">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@@ -18,7 +16,7 @@
<li><a class="dropdown-item" href="/comic/artists/">Artists</a></li> <li><a class="dropdown-item" href="/comic/artists/">Artists</a></li>
<li><a class="dropdown-item" href="/comic/publishers/">Publishers</a></li> <li><a class="dropdown-item" href="/comic/publishers/">Publishers</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li> <li><a class="dropdown-item" href="/comic/worktypes">WorkTypes</a></li>
</ul> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
@@ -26,6 +24,7 @@
<ul class="dropdown-menu" aria-labelledby="navbarDropdown"> <ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="/media/files/">MediaFiles</a></li> <li><a class="dropdown-item" href="/media/files/">MediaFiles</a></li>
<li><a class="dropdown-item" href="/media/actors/">MediaActors</a></li> <li><a class="dropdown-item" href="/media/actors/">MediaActors</a></li>
<li><a class="dropdown-item" href="/media/videos/">MediaVideos</a></li>
</ul> </ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@@ -42,18 +41,19 @@
<li><a class="dropdown-item" href="/register/">Signup</a></li> <li><a class="dropdown-item" href="/register/">Signup</a></li>
<li><a class="dropdown-item" href="/login/">Login</a></li> <li><a class="dropdown-item" href="/login/">Login</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li> <li><a class="dropdown-item" href="/admin/profiles">Profiles</a></li>
<li><a class="dropdown-item" href="/admin/permissions">Permissions</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
<ul class="navbar-nav ml-auto mb-2 mb-lg-0"> <ul class="navbar-nav ml-auto mb-2 mb-lg-0">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Jobs Media
</a> </a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown"> <ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="/comics/">Comics</a></li> <li><a class="dropdown-item" href="/media/add-link/">Add Link</a></li>
<li><a class="dropdown-item" href="/comics/">Media</a></li> <li><a class="dropdown-item" href="/media/add-file">Add MediaFile</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
@@ -41,5 +41,12 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="row">
<div>
<a href="/media/actors" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Back to list</a>
<a href="/media/actor/edit/{{actor.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a>
<a href="/media/actor/delete/{{actor.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -18,7 +18,7 @@
{% for actor in actors %} {% for actor in actors %}
<div class="col-lg-4 col-md-3 col-sm-10 mr-auto"> <div class="col-lg-4 col-md-3 col-sm-10 mr-auto">
{% with obj=actor %} {% with obj=actor %}
{% include "components/actor_cards.html" %} {% include "media/actor_cards.html" %}
{% endwith %} {% endwith %}
{% if loop.index %3 %} {% if loop.index %3 %}
@@ -0,0 +1,29 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Add a Video Link</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="text-danger font-weight-bold">
{% for error in errors %}
<li>{{error}}</li>
{% endfor %}
</div>
</div>
<div class="row my-5">
<h3 class="text-center display-4">Add a Video Link</h3>
<form method="POST">
<div class="mb-3">
<input type="text" required class="form-control" name="url" value="{{url}}" placeholder="Video Link here">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
{% endblock %}
@@ -61,5 +61,12 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="row">
<div>
<a href="/media/files" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Back to list</a>
<a href="/media/file/edit/{{mediafile.id}}" class="btn btn-outline-primary btn-sm active" role="button" aria-pressed="true">Edit</a>
<a href="/media/file/delete/{{mediafile.id}}" class="btn btn-outline-danger btn-sm active" role="button" aria-pressed="true">Delete</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
+25 -15
View File
@@ -9,21 +9,31 @@
{% include "components/alerts.html" %} {% include "components/alerts.html" %}
{% endwith %} {% endwith %}
<div class="container"> <div class="container">
<table class="table table-hover"> <div class="row">
<thead><tr> <form class="d-flex" action="/media/files/">
<th scope="col">Titel</th> <input class="form-control me-2" name="query" id="autocomplete" type="search" placeholder="Search" aria-label="Search">
<th scope="col">URL</th> Review<input type="checkbox" name="review" aria-label="Review">
<th scope="col">Cloudlink</th> Download<input type="checkbox" name="download" aria-label="Download">
</tr></thead> <button class="btn btn-outline-success" type="submit">Search</button>
<tbody> </form>
{% for mediafile in mediafiles %} </div>
<tr> <div class="row">
<th scope="row"><a href="/media/files/{{mediafile.id}}">{{mediafile.title}}</a></th> <table class="table table-hover">
<td>{{mediafile.url}}</td> <thead><tr>
<td>{{mediafile.cloud_link}}</td> <th scope="col">Titel</th>
</tr> <th scope="col">Review</th>
<th scope="col">Download</th>
</tr></thead>
<tbody>
{% for mediafile in mediafiles %}
<tr>
<th scope="row"><a href="/media/files/{{mediafile.id}}">{{mediafile.title}}</a></th>
<td>{% with check=mediafile.review %}{% include "components/check.html" %}{% endwith %}</td>
<td>{% with check=mediafile.should_download %}{% include "components/check.html" %}{% endwith %}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,55 @@
{% extends "shared/base.html" %}
{% block title %}
<title>MediaVideo Detail</title>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col">
<h1 class="display-5">MediaVideo Detail</h1>
</div>
</div>
<div class="row">
<table class="table table-striped table-hover">
<tbody>
<tr>
<th scope="row">MediaVideo Title</th>
<td colspan="2">{{mediavideo.title}}</td>
</tr>
<tr>
<th scope="row">MediaVideo URL</th>
<td colspan="2">{{mediavideo.url}}</td>
</tr>
<tr>
<th scope="row">MediaVideo Cloudlink</th>
<td colspan="2">{{mediavideo.cloud_link}}</td>
</tr>
<tr>
<th scope="row">MediaVideo Download?</th>
<td colspan="2">{{mediavideo.should_download}}</td>
</tr>
<tr>
<th scope="row">MediaVideo Review?</th>
<td colspan="2">{{mediavideo.review}}</td>
</tr>
<tr>
<th scope="row">Data Created</th>
<td colspan="2">{{mediavideo.created_date}}</td>
</tr>
<tr>
<th scope="row">Data Modified</th>
<td colspan="2">{{mediavideo.last_modified_date}}</td>
</tr>
<tr>
<th scope="row">Data Version</th>
<td colspan="2">{{mediavideo.version}}</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "shared/base.html" %}
{% block title %}
<title>Video Links</title>
{% endblock %}
{% block content %}
{% with msg=msg %}
{% include "components/alerts.html" %}
{% endwith %}
<div class="container">
<table class="table table-hover">
<thead><tr>
<th scope="col">Titel</th>
<th scope="col">Review</th>
<th scope="col">Download</th>
</tr></thead>
<tbody>
{% for mediavideo in mediavideos %}
<tr>
<th scope="row"><a href="/media/videos/{{mediavideo.id}}">{{mediavideo.title}}</a></th>
<td>{% with check=mediavideo.review %}{% include "components/check.html" %}{% endwith %}</td>
<td>{% with check=mediavideo.should_download %}{% include "components/check.html" %}{% endwith %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+1 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en-us"> <html lang="de-DE">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -21,6 +21,5 @@
{% block scripts %} {% block scripts %}
{% endblock %} {% endblock %}
</body> </body>
</html> </html>
@@ -0,0 +1,31 @@
from typing import AnyStr
from fastapi import APIRouter, Request
from fastapi.security.utils import get_authorization_scheme_param
from fastapi.templating import Jinja2Templates
from src.apis.utils import SessionDep
from src.apis.version1.admin import get_current_user_from_token
from src.db.models.admin import Permission, Profile
templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False, prefix="/admin")
@router.get("/profiles")
def get_profiles(db: SessionDep, request: Request, msg: str | None = None):
profiles = db.query(Profile).all()
return templates.TemplateResponse("admin/profiles.html", {"request": request, "msg": msg, "profiles": profiles})
@router.get("/profiles/{profile_id}")
def comic_details(profile_id: AnyStr, request: Request, db: SessionDep):
profile = db.get(Profile, profile_id)
return templates.TemplateResponse("admin/profile_detail.html", {"request": request, "profile":profile})
@router.get("/permissions")
def get_permissions(db: SessionDep, request: Request, msg: str | None = None):
permissions = db.query(Permission).all()
return templates.TemplateResponse("admin/permissions.html", {"request": request, "msg": msg, "permissions": permissions})
@router.get("/permissions/{permission_id}")
def artist_detail(permission_id: AnyStr, request: Request, db: SessionDep):
permission= db.get(Permission, str(permission_id))
return templates.TemplateResponse("comic/permission_detail.html", {"request": request, "permission": permission})
+27
View File
@@ -0,0 +1,27 @@
from typing import List
from typing import Optional
from fastapi import Request
class LoginForm:
def __init__(self, request: Request):
self.request: Request = request
self.errors: List = []
self.username: Optional[str] = None
self.password: Optional[str] = None
async def load_data(self):
form = await self.request.form()
# since auth works on username field we are considering email as username
self.username = form.get("email")
self.password = form.get("password")
async def is_valid(self):
if not self.username or not (self.username.__contains__("@")):
self.errors.append("Email is required")
if not self.password or not len(self.password) >= 4:
self.errors.append("A valid password is required")
if not self.errors:
return True
return False
@@ -0,0 +1,35 @@
from src.apis.version1.admin import login_for_access_token
from src.db.session import get_db
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Request
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from src.webapps.auth.forms import LoginForm
templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False)
@router.get("/login/")
def login(request: Request):
return templates.TemplateResponse("auth/login.html", {"request": request})
@router.post("/login/")
async def login(request: Request, db: Session = Depends(get_db)):
form = LoginForm(request)
await form.load_data()
if await form.is_valid():
try:
form.__dict__.update(msg="Login Successful :)")
response = templates.TemplateResponse("auth/login.html", form.__dict__)
login_for_access_token(response=response, form_data=form, db=db)
return response
except HTTPException:
form.__dict__.update(msg="")
form.__dict__.get("errors").append("Incorrect Email or Password")
return templates.TemplateResponse("auth/login.html", form.__dict__)
return templates.TemplateResponse("auth/login.html", form.__dict__)
+10 -3
View File
@@ -1,15 +1,22 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from src.webapps.comic import route_comics from src.webapps.admin import route_admin
from src.webapps.media import route_media from src.webapps.auth import route_login
from src.webapps.comic import route_comics, route_worktype, route_artists
from src.webapps.media import route_media, route_videos
templates = Jinja2Templates(directory="src/templates") templates = Jinja2Templates(directory="src/templates")
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(route_comics.router) api_router.include_router(route_comics.router)
api_router.include_router(route_artists.router)
api_router.include_router(route_worktype.router)
api_router.include_router(route_media.router) api_router.include_router(route_media.router)
api_router.include_router(route_videos.router)
api_router.include_router(route_login.router)
api_router.include_router(route_admin.router)
@api_router.get("/") @api_router.get("/")
def home(request: Request, msg: str = None): def home(request: Request, msg: str | None = None):
return templates.TemplateResponse("index.html", {"request": request, "msg": msg}) return templates.TemplateResponse("index.html", {"request": request, "msg": msg})
@@ -0,0 +1,27 @@
from fastapi import Request
from typing import List, Optional
class ValidateComicForm:
def __init__(self, request: Request, comic_id: str, completed: bool, current_order: bool):
self.request = request
self.errors: List = []
self.id = comic_id
self.title: Optional[str] = None
self.weblink: Optional[str] = None
self.completed = completed
self.current_order = current_order
async def load_data(self):
form = await self.request.form()
print(f"{form.keys()}")
self.title = form.get("title")
self.weblink = form.get("weblink")
def is_valid(self):
if not self.errors:
return True
return False
def __str__(self):
return f"{self.title=}, {self.weblink=}"
@@ -0,0 +1,20 @@
from fastapi import Request
from typing import List, Optional
class AddWorktypeForm:
def __init__(self, request: Request):
self.request = request
self.errors: List = []
self.worktype: Optional[str] = None
async def load_data(self):
form = await self.request.form()
self.worktype = form.get("worktype")
def is_valid(self):
if not self.worktype or (len(self.worktype) == 0):
self.errors.append("WorkType cannot be empty")
if not self.errors:
return True
return False
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Request, status
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from src.apis.utils import SessionDep
from src.db.models.comic import Artist
from typing import AnyStr
#from src.db.repository.comic import create_new_worktype, update_worktype
from src.main import logger
#from src.schema.comics.worktype import AddWorkType
#from src.webapps.comic.forms import AddWorktypeForm
templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False, prefix="/comic")
@router.get("/artists")
def get_artists(db: SessionDep, request: Request, msg: str | None = None):
artists = db.query(Artist).all()
return templates.TemplateResponse("comic/artists.html", {"request": request, "msg": msg, "artists": artists})
@router.get("/artists/{artist_id}")
def artist_detail(artist_id: AnyStr, request: Request, db: SessionDep):
artist = db.get(Artist, str(artist_id))
return templates.TemplateResponse("comic/artist_detail.html", {"request": request, "artist": artist})
@router.get("/artist/edit/{artist_id}")
def edit_artist(db: SessionDep, request: Request, artist_id: str):
artist = db.get(Artist, artist_id)
return templates.TemplateResponse("comic/artist_edit.html", {"request": request, "artist_name": artist.name, "artist_link": artist.weblink})
@router.post("/artist/edit/{artist_id}")
async def edit_artist(request: Request, db: SessionDep, artist_id: str):
form = AddArtistForm(request)
await form.load_data()
if form.is_valid():
try:
artist = AddArtist(**form.__dict__)
artist = update_artist(artist=artist, artist_id=artist_id, db=db)
return RedirectResponse(f"/comic/artists/{artist.id}", status_code=status.HTTP_303_SEE_OTHER)
except Exception as e:
print(e)
form.__dict__.get("errors").append("artist already added")
return templates.TemplateResponse("comic/artist_edit.html", form.__dict__)
return templates.TemplateResponse("comic/artist_edit.html", form.__dict__)
@router.get("/artist/delete/{artist_id}")
async def delete_artist(db: SessionDep, request: Request, artist_id: str):
artist = db.get(Artist, artist_id)
db.delete(artist)
db.commit()
return RedirectResponse("/comic/artists", status_code=status.HTTP_303_SEE_OTHER)
+65 -17
View File
@@ -1,42 +1,90 @@
from uuid import UUID from fastapi import APIRouter, Form, Request, status
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from src.apis.utils import SessionDep from src.apis.utils import SessionDep
from src.db.models.comic import Comic, Artist, Publisher from src.db.models.comic import Comic, Publisher, Issue
from typing import AnyStr
from src.core.log_conf import logger
from src.db.repository.comics.comic import update_comic
from src.schema.comics.comic import ComicSchema
from src.webapps.comic.forms.comic import ValidateComicForm
templates = Jinja2Templates(directory="src/templates") templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False, prefix="/comic") router = APIRouter(include_in_schema=False, prefix="/comic")
@router.get("/comics") @router.get("/comics")
def get_comics(db: SessionDep, request: Request, msg: str = None): def get_comics(db: SessionDep, request: Request, msg: str | None = None):
comics = db.query(Comic).all() params = request.query_params
query = params.get("query")
filter = {}
completed = params.get('completed') == "on"
if completed:
filter['completed'] = True
order = params.get("order") == "on"
if order:
filter['current_order'] = True
if query is not None and len(query) > 0:
filter['title'] = query
if len(filter) > 0:
if "title" in filter:
comics = db.query(Comic).filter(Comic.title.ilike(f'%{query}%'))
else:
comics = db.query(Comic).filter_by(**filter).all()
else:
comics = db.query(Comic).all()
return templates.TemplateResponse("comic/comics.html", {"request": request, "msg": msg, "comics": comics}) return templates.TemplateResponse("comic/comics.html", {"request": request, "msg": msg, "comics": comics})
@router.get("/comics/{comic_id}") @router.get("/comics/{comic_id}")
def comic_details(comic_id: UUID, request: Request, db: SessionDep): def comic_details(comic_id: AnyStr, request: Request, db: SessionDep):
comic = db.get(Comic, comic_id) comic = db.get(Comic, comic_id)
return templates.TemplateResponse("comic/comic_detail.html", {"request": request, "comic":comic}) return templates.TemplateResponse("comic/comic_detail.html", {"request": request, "comic":comic})
@router.get("/artists") @router.get("/comic/edit/{comic_id}")
def get_artists(db: SessionDep, request: Request, msg: str = None): def edit_comic(db: SessionDep, request: Request, comic_id: str):
artists = db.query(Artist).all() comic = db.get(Comic, comic_id)
return templates.TemplateResponse("comic/artists.html", {"request": request, "msg": msg, "artists": artists}) return templates.TemplateResponse("comic/comic_edit.html", {"request": request, "comic_title": comic.title, "comic_weblink": comic.weblink})
@router.get("/artists/{artist_id}")
def artist_detail(artist_id: UUID, request: Request, db: SessionDep):
artist = db.get(Artist, artist_id) @router.post("/comic/edit/{comic_id}")
return templates.TemplateResponse("comic/artist_detail.html", {"request": request, "artist": artist}) async def validate_comic(request: Request, db: SessionDep, comic_id: str, action: str = Form(...), completed: bool = Form(False), current_order: bool = Form(False)):
if action == "cancel":
return RedirectResponse(f"/comic/comics/{comic_id}", status_code=status.HTTP_303_SEE_OTHER)
form = ValidateComicForm(request, comic_id, completed, current_order)
logger.info(f"request: {repr(request)}")
await form.load_data()
logger.info(f"form: {form}")
if form.is_valid():
try:
comic = ComicSchema(**form.__dict__)
comic = update_comic(comic=comic, comic_id=comic_id, db=db)
return RedirectResponse(f"/comic/comics/{comic.id}", status_code=status.HTTP_303_SEE_OTHER)
except Exception as e:
print(e)
form.__dict__.get("errors").append("comic already added")
return templates.TemplateResponse("comic/comic_edit.html", form.__dict__)
return templates.TemplateResponse("comic/comic_edit.html", form.__dict__)
@router.get("/publishers") @router.get("/publishers")
def get_publishers(db: SessionDep, request: Request, msg: str = None): def get_publishers(db: SessionDep, request: Request, msg: str | None = None):
publishers = db.query(Publisher).all() publishers = db.query(Publisher).all()
return templates.TemplateResponse("comic/publishers.html", {"request": request, "publishers": publishers}) return templates.TemplateResponse("comic/publishers.html", {"request": request, "publishers": publishers})
@router.get("/publishers/{publisher_id}") @router.get("/publishers/{publisher_id}")
def publisher_details(publisher_id: UUID, request: Request, db: SessionDep, msg: str = None): def publisher_details(publisher_id: AnyStr, request: Request, db: SessionDep, msg: str = None):
publisher = db.get(Publisher, publisher_id) publisher = db.get(Publisher, publisher_id)
if publisher is None: if publisher is None:
msg = "Could not find Publisher" msg = "Could not find Publisher"
return templates.TemplateResponse("comic/publisher_detail.html", {"request": request, "msg": msg, "publisher": publisher}) return templates.TemplateResponse("comic/publisher_detail.html", {"request": request, "msg": msg, "publisher": publisher})
@router.get("/issues")
def get_issues(db: SessionDep, request: Request, msg: str | None = None):
issues = db.query(Issue).all()
return templates.TemplateResponse("comic/issues.html", {"request": request, "msg": msg, "issues": issues})
@router.get("/issues/{issue_id}")
def issue_details(issue_id: AnyStr, request: Request, db: SessionDep):
issue = db.get(Issue, issue_id)
return templates.TemplateResponse("comic/issue_detail.html", {"request": request, "issue": issue})
@@ -0,0 +1,73 @@
from fastapi import APIRouter, Request, status
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from src.apis.utils import SessionDep
from src.db.models.comic import WorkType
from typing import AnyStr
from src.db.repository.comics.worktype import create_new_worktype, update_worktype
from src.main import logger
from src.schema.comics.worktype import AddWorkType
from src.webapps.comic.forms.worktype import AddWorktypeForm
templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False, prefix="/comic")
@router.get("/worktypes")
def get_worktypes(db: SessionDep, request: Request, msg: str | None = None):
worktypes = db.query(WorkType).all()
return templates.TemplateResponse("comic/worktypes.html", {"request": request, "msg": msg, "worktypes": worktypes})
@router.get("/worktypes/{worktype_id}")
def worktype_detail(db: SessionDep, request: Request, worktype_id: AnyStr):
worktype = db.get(WorkType, worktype_id)
return templates.TemplateResponse("comic/worktype_detail.html", {"request": request, "worktype": worktype})
@router.get("/worktype/add")
def add_worktype(request: Request, db: SessionDep):
return templates.TemplateResponse("comic/worktype_edit.html", {"request": request})
@router.post("/worktype/add")
async def add_worktype(db: SessionDep, request: Request):
form = AddWorktypeForm(request)
await form.load_data()
if form.is_valid():
try:
work = AddWorkType(**form.__dict__)
worktype = create_new_worktype(work=work, db=db)
logger.info(f"add_worktype: redirect to /comic/worktypes/{worktype.id}")
return RedirectResponse(f"/comic/worktypes/{worktype.id}", status_code=status.HTTP_303_SEE_OTHER)
except Exception as e:
print(e)
form.__dict__.get("errors").append("worktype already added")
return templates.TemplateResponse("comic/worktype_edit.html", form.__dict__)
print("form is not valid")
return templates.TemplateResponse("comic/worktype_edit.html", form.__dict__)
@router.get("/worktype/edit/{worktype_id}")
def edit_worktype(db: SessionDep, request: Request, worktype_id: str):
worktype = db.get(WorkType, worktype_id)
return templates.TemplateResponse("comic/worktype_edit.html", {"request": request, "worktype": worktype.name})
@router.post("/worktype/edit/{worktype_id}")
async def edit_worktype(request: Request, db: SessionDep, worktype_id: str):
form = AddWorktypeForm(request)
await form.load_data()
if form.is_valid():
try:
work = AddWorkType(**form.__dict__)
worktype = update_worktype(work=work, worktype_id=worktype_id, db=db)
return RedirectResponse(f"/comic/worktypes/{worktype.id}", status_code=status.HTTP_303_SEE_OTHER)
except Exception as e:
print(e)
form.__dict__.get("errors").append("worktype already added")
return templates.TemplateResponse("comic/worktype_edit.html", form.__dict__)
return templates.TemplateResponse("comic/worktype_edit.html", form.__dict__)
@router.get("/worktype/delete/{worktype_id}")
async def delete_worktype(db: SessionDep, request: Request, worktype_id: str):
worktype = db.get(WorkType, worktype_id)
db.delete(worktype)
db.commit()
return RedirectResponse("/comic/worktypes", status_code=status.HTTP_303_SEE_OTHER)
+20
View File
@@ -0,0 +1,20 @@
from fastapi import Request
from typing import List, Optional
class AddLinkForm:
def __init__(self, request: Request):
self.request = request
self.errors: List = []
self.url: Optional[str] = None
async def load_data(self):
form = await self.request.form()
self.url = form.get("url")
def is_valid(self):
if not self.url or not (self.url.__contains__("http")):
self.errors.append("Valid Url is required e.g. https://example.com")
if not self.errors:
return True
return False
+34 -6
View File
@@ -1,22 +1,50 @@
from uuid import UUID from typing import AnyStr
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.security.utils import get_authorization_scheme_param
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import or_
from src.apis.utils import SessionDep from src.apis.utils import SessionDep
from src.apis.version1.admin import get_current_user_from_token
from src.db.models.admin import Profile
from src.db.models.media import MediaFile, MediaActor from src.db.models.media import MediaFile, MediaActor
#ifrom src.schema.media.comic import get_comic_details
templates = Jinja2Templates(directory="src/templates") templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False, prefix="/media") router = APIRouter(include_in_schema=False, prefix="/media")
@router.get("/files") @router.get("/files")
def get_mediafiles(db: SessionDep, request: Request, msg: str = None): def get_mediafiles(db: SessionDep, request: Request, msg: str = None):
mediafiles = db.query(MediaFile).all() params = request.query_params
return templates.TemplateResponse("media/files.html", {"request": request, "msg": msg, "mediafiles": mediafiles}) query = params.get("query")
filter = {}
review = params.get('review') == "on"
if review:
filter['review'] = True
download = params.get("download") == "on"
if download:
filter['should_download'] = True
if query is not None and len(query) > 0:
filter['url'] = query
if len(filter) > 0:
if "url" in filter:
mediafiles = db.query(MediaFile).filter(or_(MediaFile.title.ilike(f'%{query}%'), MediaFile.url.ilike(f"%{query}%")))
else:
mediafiles = db.query(MediaFile).filter_by(**filter).all()
else:
mediafiles = db.query(MediaFile).all()
try:
token = request.cookies.get("access_token")
scheme, param = get_authorization_scheme_param(token) # scheme will hold "Bearer" and param will hold actual token value
current_user: Profile = get_current_user_from_token(token=param, db=db)
return templates.TemplateResponse("media/files.html", {"request": request, "msg": msg, "mediafiles": mediafiles})
except Exception as e:
print(e)
msg = "Nicht berechtigt!!"
return templates.TemplateResponse("media/files.html", {"request": request, "msg": msg, "mediafiles": []})
@router.get("/files/{file_id}") @router.get("/files/{file_id}")
def file_details(file_id: UUID, request: Request, db: SessionDep): def file_details(file_id: AnyStr, request: Request, db: SessionDep):
mediafile = db.get(MediaFile, file_id) mediafile = db.get(MediaFile, file_id)
return templates.TemplateResponse("media/file_detail.html", {"request": request, "mediafile":mediafile}) return templates.TemplateResponse("media/file_detail.html", {"request": request, "mediafile":mediafile})
@@ -26,7 +54,7 @@ def get_actors(db: SessionDep, request: Request, msg: str = None):
return templates.TemplateResponse("media/actors.html", {"request": request, "msg": msg, "actors": actors}) return templates.TemplateResponse("media/actors.html", {"request": request, "msg": msg, "actors": actors})
@router.get("/actors/{actor_id}") @router.get("/actors/{actor_id}")
def artist_detail(actor_id: UUID, request: Request, db: SessionDep): def artist_detail(actor_id: AnyStr, request: Request, db: SessionDep):
actor = db.get(MediaActor, actor_id) actor = db.get(MediaActor, actor_id)
return templates.TemplateResponse("media/actor_detail.html", {"request": request, "actor": actor}) return templates.TemplateResponse("media/actor_detail.html", {"request": request, "actor": actor})
@@ -0,0 +1,53 @@
from typing import AnyStr
from fastapi import APIRouter, Request, status, responses
from fastapi.security.utils import get_authorization_scheme_param
from fastapi.templating import Jinja2Templates
from src.apis.utils import SessionDep
from src.db.models.media import MediaVideo
from src.db.repository.media import create_new_video
from src.apis.version1.admin import get_current_user_from_token
from src.db.models.admin import Profile
from src.schema.media.video import AddLink
from src.webapps.media.forms import AddLinkForm
templates = Jinja2Templates(directory="src/templates")
router = APIRouter(include_in_schema=False, prefix="/media")
@router.get("/videos")
def get_mediavideos(db: SessionDep, request: Request, msg: str = None):
mediavideos = db.query(MediaVideo).all()
try:
token = request.cookies.get("access_token")
_, param = get_authorization_scheme_param(token) # scheme will hold "Bearer" and param will hold actual token value
current_user: Profile = get_current_user_from_token(token=param, db=db)
return templates.TemplateResponse("media/videos.html", {"request": request, "msg": msg, "user": current_user, "mediavideos": mediavideos})
except Exception as e:
print(e)
return templates.TemplateResponse("media/videos.html", {"request": request, "msg": msg, "user": None, "mediavideos": mediavideos})
@router.get("/videos/{video_id}")
def video_details(video_id: AnyStr, request: Request, db: SessionDep):
mediavideo = db.get(MediaVideo, video_id)
return templates.TemplateResponse("media/video_detail.html", {"request": request, "mediavideo":mediavideo})
@router.get("/add-link")
def add_video_link(request: Request, db: SessionDep):
return templates.TemplateResponse("media/add_video_link.html", {"request": request})
@router.post("/add-link")
async def post_video_link(request: Request, db: SessionDep):
form = AddLinkForm(request)
await form.load_data()
if form.is_valid():
try:
video = AddLink(**form.__dict__)
mediavideo = create_new_video(video=video, db=db)
return responses.RedirectResponse(f"media/videos/{mediavideo.id}", status_code=status.HTTP_302_FOUND)
except Exception as e:
print(e)
form.__dict__.get("errors").append("Link already added")
return templates.TemplateResponse("media/add_video_link.html", form.__dict__)
return templates.TemplateResponse("media/add_video_link.html", form.__dict__)
+75
View File
@@ -0,0 +1,75 @@
import os
import sys
from typing import Any
from typing import Generator
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.apis.base import api_router
from src.db.models.base import Base
from src.db.session import get_db
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# this is to include backend dir in sys.path so that we can import from db,main.py
def start_application():
app = FastAPI()
app.include_router(api_router)
return app
SQLALCHEMY_DATABASE_URL = "sqlite:///./test_db.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
# Use connect_args parameter only with sqlite
SessionTesting = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="module")
def app() -> Generator[FastAPI, Any, None]:
"""
Create a fresh database on each test case.
"""
Base.metadata.create_all(engine) # Create the tables.
_app = start_application()
yield _app
Base.metadata.drop_all(engine)
@pytest.fixture(scope="module")
def db_session(app: FastAPI) -> Generator[SessionTesting, Any, None]:
connection = engine.connect()
transaction = connection.begin()
session = SessionTesting(bind=connection)
yield session # use the session in tests.
session.close()
transaction.rollback()
connection.close()
@pytest.fixture(scope="module")
def client(
app: FastAPI, db_session: SessionTesting
) -> Generator[TestClient, Any, None]:
"""
Create a new FastAPI TestClient that uses the `db_session` fixture to override
the `get_db` dependency that is injected into routes.
"""
def _get_test_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = _get_test_db
with TestClient(app) as client:
yield client
+2 -10
View File
@@ -1,15 +1,7 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import pytest
from src.main import app
@pytest.fixture(name="client")
def client_fixture():
client = TestClient(app)
yield client
def test_get_artists(client: TestClient): def test_get_artists(client: TestClient):
response = client.get("/comic/artists") response = client.get("/api/comic/artists")
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()) == 5 assert len(response.json()) == 0
+400 -240
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
"""
read file with URLs and store in DB
"""
import logging.config
import requests
import yaml
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from pathlib import Path
from platformdirs import PlatformDirs
from proton import Message, Event
from proton.handlers import MessagingHandler
from proton.reactor import Container
parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('-u', '--url', help='link')
parser.add_argument('--video', help='store Url as VideoFile', action="store_true")
parser.add_argument("--api", help="use Kontor API", action="store_true")
parser.add_argument('--config', '-c', default='kontor-docker')
parser.add_argument('--verbose', '-v', action='count', default=0)
args = parser.parse_args()
def get_logger(level: int, config: str):
dirs = PlatformDirs(config)
logging_config = Path(dirs.user_config_dir, 'logging-config.yaml')
with open(logging_config, 'rt') as f:
configDict = yaml.safe_load(f.read())
logging.config.dictConfig(configDict)
logger = logging.getLogger('development')
if level is not None:
match level:
case 0:
logger.setLevel(logging.INFO)
case 1:
logger.setLevel(logging.DEBUG)
case _:
logger.setLevel(logging.CRITICAL)
return logger
class AddLinkMessage(MessagingHandler):
def __init__(self, server, url, log):
super(AddLinkMessage, self).__init__()
log.info("create AddLinkMessage")
self.server = server
self.address = "add_link_file"
self.url = url
self.log = log
def on_start(self, event: Event):
self.log.info("Connection...")
conn = event.container.connect(self.server, user="artemis", password="artemis")
event.container.create_sender(conn, self.address)
def on_connection_error(self, event: Event) -> None:
self.log.info(f"error: {event}")
def on_sendable(self, event: Event):
self.log.info("send message")
event.sender.send(Message(body=self.url, address=self.address, content_type="text/json"))
event.connection.close()
event.sender.close()
def on_accepted(self, event: Event) -> None:
self.log.info(f"accepted: {event}")
if __name__ == '__main__':
logger = get_logger(args.verbose, args.config)
logger.info('kontor.add_link started')
link: str = args.url
data = {"url": link}
if args.api:
if args.video:
request: str = "http://127.0.0.1:8800/api/video/files"
else:
request: str = "http://127.0.0.1:8800/api/media/files"
response = requests.post(request, json=data)
logger.info(f"Status: {response.status_code}")
data = response.json()
else:
Container(AddLinkMessage("amqp://127.0.0.1:5672", data, logger)).run()
logger.info('kontor.add_link finished')
@@ -0,0 +1 @@
3.13
@@ -0,0 +1,50 @@
## ------------------------------- Builder Stage ------------------------------ ##
FROM python:3.13-bookworm AS builder
RUN apt-get update && apt-get install --no-install-recommends -y build-essential && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Download the latest installer, install it and then remove it
ADD https://astral.sh/uv/install.sh /install.sh
RUN chmod -R 655 /install.sh && /install.sh && rm /install.sh
# Set up the UV environment path correctly
ENV PATH="/root/.local/bin:${PATH}"
WORKDIR /app
COPY ./pyproject.toml .
RUN uv sync
# ------------------------------- Production Stage ------------------------------ ##
FROM python:3.13-slim-bookworm AS production
# The following secrets are available during build time
#RUN --mount=type=secret,id=DB_PASSWORD \
# --mount=type=secret,id=DB_USER \
# --mount=type=secret,id=DB_NAME \
# --mount=type=secret,id=DB_HOST \
# --mount=type=secret,id=DB_PORT \
# DB_PASSWORD=/run/secrets/DB_PASSWORD \
# DB_USER=$(cat /run/secrets/DB_USER) \
# DB_NAME=$(cat /run/secrets/DB_NAME) \
# DB_HOST=$(cat /run/secrets/DB_HOST) \
# DB_PORT=$(cat /run/secrets/DB_PORT)
#RUN --mount=type=secret,id=secret-key,target=secrets.json
RUN useradd --create-home appuser
USER appuser
WORKDIR /app
COPY /src src
COPY --from=builder /app/.venv .venv
# Set up environment variables for production
ENV PATH="/app/.venv/bin:$PATH"
# Start the application with Uvicorn in production mode, using environment variable references
CMD ["python", "src/main.py", "--log-level", "info"]
@@ -0,0 +1,16 @@
[project]
name = "kontor-domain"
version = "0.2.0"
description = "Example setup of DDD"
readme = "README.md"
authors = [
{name = "Thomas Peetz", email = "thomas.peetz@thpeetz.de"}
]
maintainers = [
{name = "Thomas Peetz", email = "thomas.peetz@thpeetz.de"}
]
requires-python = ">=3.13"
dependencies = [
"python-qpid-proton>=0.40.0",
]
@@ -0,0 +1,6 @@
def main():
print("Hello from kontor-domain!")
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
]
[[package]]
name = "kontor-domain"
version = "0.2.0"
source = { virtual = "." }
dependencies = [
{ name = "python-qpid-proton" },
]
[package.metadata]
requires-dist = [{ name = "python-qpid-proton", specifier = ">=0.40.0" }]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
name = "python-qpid-proton"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/dd/e9e5066009517bdfee92374264a2b6794fa0987bfeddcbf4d2a08dccaf36/python_qpid_proton-0.40.0.tar.gz", hash = "sha256:7680d607cf6e9684f97bf5b2ba16cda7d8512aab9e4ff78f98d44a4644fc819a", size = 354215, upload-time = "2025-05-19T18:45:37.932Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/dd/a82c1e377f08d62d83898c1aa9b39aef890e910f683fca6dc5242a123f6b/python_qpid_proton-0.40.0-cp313-cp313-win_amd64.whl", hash = "sha256:a19d8c71c908700ceb38f6cbc1eb4a039428570f96bfc2caeeafdfec804fb94f", size = 277376, upload-time = "2025-05-19T19:39:31.201Z" },
]
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kontor-schema" name = "kontor-schema"
version = "0.1.0" version = "0.2.0"
description = "Kontor Schema Library" description = "Kontor Schema Library"
readme = "README.md" readme = "README.md"
authors = [ authors = [
+59 -59
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 1 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -10,79 +10,79 @@ dependencies = [
{ name = "soupsieve" }, { name = "soupsieve" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload_time = "2025-04-15T17:05:13.836Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload_time = "2025-04-15T17:05:12.221Z" },
] ]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.1.31" version = "2025.1.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload_time = "2025-01-31T02:16:47.166Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload_time = "2025-01-31T02:16:45.015Z" },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.1" version = "3.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload_time = "2024-12-24T18:12:35.43Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload_time = "2024-12-24T18:11:05.834Z" },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload_time = "2024-12-24T18:11:07.064Z" },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload_time = "2024-12-24T18:11:08.374Z" },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload_time = "2024-12-24T18:11:09.831Z" },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload_time = "2024-12-24T18:11:12.03Z" },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload_time = "2024-12-24T18:11:13.372Z" },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload_time = "2024-12-24T18:11:14.628Z" },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload_time = "2024-12-24T18:11:17.672Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload_time = "2024-12-24T18:11:18.989Z" },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload_time = "2024-12-24T18:11:21.507Z" },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload_time = "2024-12-24T18:11:22.774Z" },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload_time = "2024-12-24T18:11:24.139Z" },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload_time = "2024-12-24T18:11:26.535Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload_time = "2024-12-24T18:12:32.852Z" },
] ]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.2.0" version = "3.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/9c/666d8c71b18d0189cf801c0e0b31c4bfc609ac823883286045b1f3ae8994/greenlet-3.2.0.tar.gz", hash = "sha256:1d2d43bd711a43db8d9b9187500e6432ddb4fafe112d082ffabca8660a9e01a7", size = 183685 } sdist = { url = "https://files.pythonhosted.org/packages/b0/9c/666d8c71b18d0189cf801c0e0b31c4bfc609ac823883286045b1f3ae8994/greenlet-3.2.0.tar.gz", hash = "sha256:1d2d43bd711a43db8d9b9187500e6432ddb4fafe112d082ffabca8660a9e01a7", size = 183685, upload_time = "2025-04-15T16:21:26.141Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/43/c0b655d4d7eae19282b028bcec449e5c80626ad0d8d0ca3703f9b1c29258/greenlet-3.2.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b86a3ccc865ae601f446af042707b749eebc297928ea7bd0c5f60c56525850be", size = 269131 }, { url = "https://files.pythonhosted.org/packages/c9/43/c0b655d4d7eae19282b028bcec449e5c80626ad0d8d0ca3703f9b1c29258/greenlet-3.2.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b86a3ccc865ae601f446af042707b749eebc297928ea7bd0c5f60c56525850be", size = 269131, upload_time = "2025-04-15T16:19:19.469Z" },
{ url = "https://files.pythonhosted.org/packages/7c/7d/c8f51c373c7f7ac0f73d04a6fd77ab34f6f643cb41a0d186d05ba96708e7/greenlet-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144283ad88ed77f3ebd74710dd419b55dd15d18704b0ae05935766a93f5671c5", size = 637323 }, { url = "https://files.pythonhosted.org/packages/7c/7d/c8f51c373c7f7ac0f73d04a6fd77ab34f6f643cb41a0d186d05ba96708e7/greenlet-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144283ad88ed77f3ebd74710dd419b55dd15d18704b0ae05935766a93f5671c5", size = 637323, upload_time = "2025-04-15T16:49:02.677Z" },
{ url = "https://files.pythonhosted.org/packages/89/65/c3ee41b2e56586737d6e124b250583695628ffa6b324855b3a1267a8d1d9/greenlet-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5be69cd50994b8465c3ad1467f9e63001f76e53a89440ad4440d1b6d52591280", size = 651430 }, { url = "https://files.pythonhosted.org/packages/89/65/c3ee41b2e56586737d6e124b250583695628ffa6b324855b3a1267a8d1d9/greenlet-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5be69cd50994b8465c3ad1467f9e63001f76e53a89440ad4440d1b6d52591280", size = 651430, upload_time = "2025-04-15T16:50:43.445Z" },
{ url = "https://files.pythonhosted.org/packages/f0/07/33bd7a3dcde1db7259371d026ce76be1eb653d2d892334fc79a500b3c5ee/greenlet-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47aeadd1e8fbdef8fdceb8fb4edc0cbb398a57568d56fd68f2bc00d0d809e6b6", size = 645798 }, { url = "https://files.pythonhosted.org/packages/f0/07/33bd7a3dcde1db7259371d026ce76be1eb653d2d892334fc79a500b3c5ee/greenlet-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47aeadd1e8fbdef8fdceb8fb4edc0cbb398a57568d56fd68f2bc00d0d809e6b6", size = 645798, upload_time = "2025-04-15T16:55:03.795Z" },
{ url = "https://files.pythonhosted.org/packages/35/5b/33c221a6a867030b0b770513a1b78f6c30e04294131dafdc8da78906bbe6/greenlet-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18adc14ab154ca6e53eecc9dc50ff17aeb7ba70b7e14779b26e16d71efa90038", size = 648271 }, { url = "https://files.pythonhosted.org/packages/35/5b/33c221a6a867030b0b770513a1b78f6c30e04294131dafdc8da78906bbe6/greenlet-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18adc14ab154ca6e53eecc9dc50ff17aeb7ba70b7e14779b26e16d71efa90038", size = 648271, upload_time = "2025-04-15T16:22:42.458Z" },
{ url = "https://files.pythonhosted.org/packages/4d/dd/d6452248fa6093504e3b7525dc2bdc4e55a4296ec6ee74ba241a51d852e2/greenlet-3.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8622b33d8694ec373ad55050c3d4e49818132b44852158442e1931bb02af336", size = 606779 }, { url = "https://files.pythonhosted.org/packages/4d/dd/d6452248fa6093504e3b7525dc2bdc4e55a4296ec6ee74ba241a51d852e2/greenlet-3.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8622b33d8694ec373ad55050c3d4e49818132b44852158442e1931bb02af336", size = 606779, upload_time = "2025-04-15T16:22:41.417Z" },
{ url = "https://files.pythonhosted.org/packages/9d/24/160f04d2589bcb15b8661dcd1763437b22e01643626899a4139bf98f02af/greenlet-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e8ac9a2c20fbff3d0b853e9ef705cdedb70d9276af977d1ec1cde86a87a4c821", size = 1117968 }, { url = "https://files.pythonhosted.org/packages/9d/24/160f04d2589bcb15b8661dcd1763437b22e01643626899a4139bf98f02af/greenlet-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e8ac9a2c20fbff3d0b853e9ef705cdedb70d9276af977d1ec1cde86a87a4c821", size = 1117968, upload_time = "2025-04-15T16:52:53.627Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ff/c6e3f3a5168fef5209cfd9498b2b5dd77a0bf29dfc686a03dcc614cf4432/greenlet-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:cd37273dc7ca1d5da149b58c8b3ce0711181672ba1b09969663905a765affe21", size = 1145510 }, { url = "https://files.pythonhosted.org/packages/6c/ff/c6e3f3a5168fef5209cfd9498b2b5dd77a0bf29dfc686a03dcc614cf4432/greenlet-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:cd37273dc7ca1d5da149b58c8b3ce0711181672ba1b09969663905a765affe21", size = 1145510, upload_time = "2025-04-15T16:23:01.873Z" },
{ url = "https://files.pythonhosted.org/packages/dc/62/5215e374819052e542b5bde06bd7d4a171454b6938c96a2384f21cb94279/greenlet-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8a8940a8d301828acd8b9f3f85db23069a692ff2933358861b19936e29946b95", size = 296004 }, { url = "https://files.pythonhosted.org/packages/dc/62/5215e374819052e542b5bde06bd7d4a171454b6938c96a2384f21cb94279/greenlet-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8a8940a8d301828acd8b9f3f85db23069a692ff2933358861b19936e29946b95", size = 296004, upload_time = "2025-04-15T16:55:46.007Z" },
{ url = "https://files.pythonhosted.org/packages/62/6d/dc9c909cba5cbf4b0833fce69912927a8ca74791c23c47b9fd4f28092108/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee59db626760f1ca8da697a086454210d36a19f7abecc9922a2374c04b47735b", size = 629900 }, { url = "https://files.pythonhosted.org/packages/62/6d/dc9c909cba5cbf4b0833fce69912927a8ca74791c23c47b9fd4f28092108/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee59db626760f1ca8da697a086454210d36a19f7abecc9922a2374c04b47735b", size = 629900, upload_time = "2025-04-15T16:49:04.099Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a9/f3f304fbbbd604858ff3df303d7fa1d8f7f9e45a6ef74481aaf03aaac021/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7154b13ef87a8b62fc05419f12d75532d7783586ad016c57b5de8a1c6feeb517", size = 635270 }, { url = "https://files.pythonhosted.org/packages/5e/a9/f3f304fbbbd604858ff3df303d7fa1d8f7f9e45a6ef74481aaf03aaac021/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7154b13ef87a8b62fc05419f12d75532d7783586ad016c57b5de8a1c6feeb517", size = 635270, upload_time = "2025-04-15T16:50:44.769Z" },
{ url = "https://files.pythonhosted.org/packages/34/92/4b7b4e2e23ecc723cceef9fe3898e78c8e14e106cc7ba2f276a66161da3e/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:199453d64b02d0c9d139e36d29681efd0e407ed8e2c0bf89d88878d6a787c28f", size = 632534 }, { url = "https://files.pythonhosted.org/packages/34/92/4b7b4e2e23ecc723cceef9fe3898e78c8e14e106cc7ba2f276a66161da3e/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:199453d64b02d0c9d139e36d29681efd0e407ed8e2c0bf89d88878d6a787c28f", size = 632534, upload_time = "2025-04-15T16:55:05.203Z" },
{ url = "https://files.pythonhosted.org/packages/da/7f/91f0ecbe72c9d789fb7f400b39da9d1e87fcc2cf8746a9636479ba79ab01/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0010e928e1901d36625f21d008618273f9dda26b516dbdecf873937d39c9dff0", size = 628826 }, { url = "https://files.pythonhosted.org/packages/da/7f/91f0ecbe72c9d789fb7f400b39da9d1e87fcc2cf8746a9636479ba79ab01/greenlet-3.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0010e928e1901d36625f21d008618273f9dda26b516dbdecf873937d39c9dff0", size = 628826, upload_time = "2025-04-15T16:22:44.545Z" },
{ url = "https://files.pythonhosted.org/packages/9f/59/e449a44ce52b13751f55376d85adc155dd311608f6d2aa5b6bd2c8d15486/greenlet-3.2.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6005f7a86de836a1dc4b8d824a2339cdd5a1ca7cb1af55ea92575401f9952f4c", size = 593697 }, { url = "https://files.pythonhosted.org/packages/9f/59/e449a44ce52b13751f55376d85adc155dd311608f6d2aa5b6bd2c8d15486/greenlet-3.2.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6005f7a86de836a1dc4b8d824a2339cdd5a1ca7cb1af55ea92575401f9952f4c", size = 593697, upload_time = "2025-04-15T16:22:43.796Z" },
{ url = "https://files.pythonhosted.org/packages/bb/09/cca3392927c5c990b7a8ede64ccd0712808438d6490d63ce6b8704d6df5f/greenlet-3.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:17fd241c0d50bacb7ce8ff77a30f94a2d0ca69434ba2e0187cf95a5414aeb7e1", size = 1105762 }, { url = "https://files.pythonhosted.org/packages/bb/09/cca3392927c5c990b7a8ede64ccd0712808438d6490d63ce6b8704d6df5f/greenlet-3.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:17fd241c0d50bacb7ce8ff77a30f94a2d0ca69434ba2e0187cf95a5414aeb7e1", size = 1105762, upload_time = "2025-04-15T16:52:55.245Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b9/3d201f819afc3b7a8cd7ebe645f1a17799603e2d62c968154518f79f4881/greenlet-3.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:7b17a26abc6a1890bf77d5d6b71c0999705386b00060d15c10b8182679ff2790", size = 1125173 }, { url = "https://files.pythonhosted.org/packages/4d/b9/3d201f819afc3b7a8cd7ebe645f1a17799603e2d62c968154518f79f4881/greenlet-3.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:7b17a26abc6a1890bf77d5d6b71c0999705386b00060d15c10b8182679ff2790", size = 1125173, upload_time = "2025-04-15T16:23:03.009Z" },
{ url = "https://files.pythonhosted.org/packages/80/7b/773a30602234597fc2882091f8e1d1a38ea0b4419d99ca7ed82c827e2c3a/greenlet-3.2.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:397b6bbda06f8fe895893d96218cd6f6d855a6701dc45012ebe12262423cec8b", size = 269908 }, { url = "https://files.pythonhosted.org/packages/80/7b/773a30602234597fc2882091f8e1d1a38ea0b4419d99ca7ed82c827e2c3a/greenlet-3.2.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:397b6bbda06f8fe895893d96218cd6f6d855a6701dc45012ebe12262423cec8b", size = 269908, upload_time = "2025-04-15T16:20:33.58Z" },
] ]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]] [[package]]
name = "kontor-schema" name = "kontor-schema"
version = "0.1.0" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
@@ -107,18 +107,18 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload_time = "2024-05-29T15:37:49.536Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload_time = "2024-05-29T15:37:47.027Z" },
] ]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.7" version = "2.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload_time = "2025-04-20T18:50:08.518Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload_time = "2025-04-20T18:50:07.196Z" },
] ]
[[package]] [[package]]
@@ -129,33 +129,33 @@ dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299 } sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299, upload_time = "2025-03-27T17:52:31.876Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887 }, { url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887, upload_time = "2025-03-27T18:40:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367 }, { url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367, upload_time = "2025-03-27T18:40:07.182Z" },
{ url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806 }, { url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806, upload_time = "2025-03-27T18:51:29.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131 }, { url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131, upload_time = "2025-03-27T18:50:31.616Z" },
{ url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364 }, { url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364, upload_time = "2025-03-27T18:51:31.336Z" },
{ url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482 }, { url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482, upload_time = "2025-03-27T18:50:33.201Z" },
{ url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704 }, { url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704, upload_time = "2025-03-27T18:46:00.193Z" },
{ url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564 }, { url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564, upload_time = "2025-03-27T18:46:01.442Z" },
{ url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894 }, { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894, upload_time = "2025-03-27T18:40:43.796Z" },
] ]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.13.2" version = "4.13.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" },
] ]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.4.0" version = "2.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload_time = "2025-04-10T15:23:39.232Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload_time = "2025-04-10T15:23:37.377Z" },
] ]

Some files were not shown because too many files have changed in this diff Show More