Compare commits
3 commits
0d884e5836
...
07aac38dbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07aac38dbc | ||
|
|
13779d5a74 | ||
|
|
c3cd7c2796 |
14 changed files with 69 additions and 39 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
|
"dev_mode": false,
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info"
|
"level": "info"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,18 @@ with python313Packages;
|
||||||
buildPythonApplication {
|
buildPythonApplication {
|
||||||
pname = "cereal";
|
pname = "cereal";
|
||||||
version = "0.0.1";
|
version = "0.0.1";
|
||||||
propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography jinja2];
|
propagatedBuildInputs = [
|
||||||
|
flask
|
||||||
|
requests
|
||||||
|
waitress
|
||||||
|
sqlalchemy
|
||||||
|
argon2-cffi
|
||||||
|
pyjwt
|
||||||
|
cryptography
|
||||||
|
jinja2
|
||||||
|
flask-talisman
|
||||||
|
flask-cors
|
||||||
|
];
|
||||||
src = ./.;
|
src = ./.;
|
||||||
pyproject = true;
|
pyproject = true;
|
||||||
build-system = [setuptools];
|
build-system = [setuptools];
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ let
|
||||||
pyjwt
|
pyjwt
|
||||||
cryptography
|
cryptography
|
||||||
jinja2
|
jinja2
|
||||||
|
flask-talisman
|
||||||
|
flask-cors
|
||||||
]);
|
]);
|
||||||
in
|
in
|
||||||
with pkgs;
|
with pkgs;
|
||||||
|
|
|
||||||
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
|
|
@ -1,7 +1,6 @@
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .logging import Logging
|
from .logging import Logging
|
||||||
from .email import Email
|
from .email import Email
|
||||||
from .auth import Auth
|
from .auth import Auth
|
||||||
|
|
@ -13,6 +12,7 @@ from .parse import parse_nested_config
|
||||||
class Config:
|
class Config:
|
||||||
host: str
|
host: str
|
||||||
port: int | None
|
port: int | None
|
||||||
|
dev_mode: bool
|
||||||
logging: Logging
|
logging: Logging
|
||||||
email: Email
|
email: Email
|
||||||
database: Database
|
database: Database
|
||||||
|
|
@ -25,9 +25,17 @@ class Config:
|
||||||
db_config = parse_nested_config(config, 'database', Database.from_dict)
|
db_config = parse_nested_config(config, 'database', Database.from_dict)
|
||||||
auth_config = parse_nested_config(config, 'auth', Auth.from_dict)
|
auth_config = parse_nested_config(config, 'auth', Auth.from_dict)
|
||||||
|
|
||||||
|
mode = config.get('dev_mode')
|
||||||
|
|
||||||
|
if mode != True: # noqa: E712
|
||||||
|
# Explicitly checking for True and falling back to false for anything else. Only explicitly setting
|
||||||
|
# dev_mode to true will run in dev mode
|
||||||
|
mode = False
|
||||||
|
|
||||||
return Config(
|
return Config(
|
||||||
host=config.get('host', '0.0.0.0'),
|
host=config.get('host', '0.0.0.0'),
|
||||||
port=config.get('port'),
|
port=config.get('port'),
|
||||||
|
dev_mode=mode,
|
||||||
email=email_config,
|
email=email_config,
|
||||||
logging=log_config,
|
logging=log_config,
|
||||||
database=db_config,
|
database=db_config,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from .db import Base, CRUD, CRUDRepo, UpdateMissingEntryError, get_database
|
from .db import Base, CRUDRepoImpl, UpdateMissingEntryError, get_database
|
||||||
from .users import UserModel, UserRepoImpl
|
from .users import UserModel, UserRepoImpl
|
||||||
from .publication import (
|
from .publication import (
|
||||||
PublicationEntryModel,
|
PublicationEntryModel,
|
||||||
|
|
@ -11,7 +11,6 @@ from .subscription import SubscriptionModel, SubscriptionRepoImpl
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Base',
|
'Base',
|
||||||
'CRUD',
|
|
||||||
'CRUDRepo',
|
'CRUDRepo',
|
||||||
'UpdateMissingEntryError',
|
'UpdateMissingEntryError',
|
||||||
'get_database',
|
'get_database',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
from src.services.repo import CRUD
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session
|
from sqlalchemy.orm import DeclarativeBase, Session
|
||||||
from src.config.database import Database as DatabaseConfig
|
from src.config.database import Database as DatabaseConfig
|
||||||
from sqlalchemy import create_engine, Engine
|
from sqlalchemy import create_engine, Engine
|
||||||
import abc
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|
@ -22,25 +22,7 @@ class UpdateMissingEntryError(Exception):
|
||||||
super().__init__(f'Attempted to update non-existing {model_name} {id}')
|
super().__init__(f'Attempted to update non-existing {model_name} {id}')
|
||||||
|
|
||||||
|
|
||||||
class CRUD[T, C](abc.ABC):
|
class CRUDRepoImpl[T: Base, U, C](CRUD[U, C]):
|
||||||
@abc.abstractmethod
|
|
||||||
def create(self, domain_item: C, verify: Callable[[T], None] | None) -> T:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def get(self, item_id: int) -> T | None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def update(self, item_id: int, transform: Callable[[T], T]) -> T:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def delete(self, item_id: int, verify: Callable[[T], None] | None):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CRUDRepo[T: Base, U, C](CRUD[U, C]):
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db: Engine,
|
db: Engine,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
from sqlalchemy import Engine
|
from sqlalchemy import Engine
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from src.infra import CRUDRepo, Base
|
from src.infra import CRUDRepoImpl, Base
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
class CRUDRepoHelper[A: Base, B, C]:
|
class CRUDRepoHelper[A: Base, B, C]:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repo(self, db: Engine) -> CRUDRepo[A, B, C]:
|
def repo(self, db: Engine) -> CRUDRepoImpl[A, B, C]:
|
||||||
import pdb
|
import pdb
|
||||||
|
|
||||||
pdb.set_trace()
|
pdb.set_trace()
|
||||||
|
|
@ -17,7 +17,7 @@ class CRUDRepoHelper[A: Base, B, C]:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def item(self, repo: CRUDRepo[A, B, C], dto: C) -> B:
|
def item(self, repo: CRUDRepoImpl[A, B, C], dto: C) -> B:
|
||||||
return repo.create(dto, None)
|
return repo.create(dto, None)
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -31,26 +31,28 @@ class CRUDRepoHelper[A: Base, B, C]:
|
||||||
def test_create(self, item: B):
|
def test_create(self, item: B):
|
||||||
assert item is not None
|
assert item is not None
|
||||||
|
|
||||||
def test_create_returns_none_if_invalid(self, repo: CRUDRepo[A, B, C], dto: C):
|
def test_create_returns_none_if_invalid(self, repo: CRUDRepoImpl[A, B, C], dto: C):
|
||||||
def always_invalid(*_args, **_kw):
|
def always_invalid(*_args, **_kw):
|
||||||
raise ValueError('test')
|
raise ValueError('test')
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
repo.create(dto, always_invalid)
|
repo.create(dto, always_invalid)
|
||||||
|
|
||||||
def test_create_returns_a_value_if_valid(self, repo: CRUDRepo[A, B, C], dto: C):
|
def test_create_returns_a_value_if_valid(self, repo: CRUDRepoImpl[A, B, C], dto: C):
|
||||||
assert repo.create(dto, lambda _: None) is not None
|
assert repo.create(dto, lambda _: None) is not None
|
||||||
|
|
||||||
def test_get_returns_none_when_missing(self, repo: CRUDRepo[A, B, C]):
|
def test_get_returns_none_when_missing(self, repo: CRUDRepoImpl[A, B, C]):
|
||||||
assert repo.get(42) is None
|
assert repo.get(42) is None
|
||||||
|
|
||||||
def test_get_returns_a_value_if_it_exists(self, repo: CRUDRepo[A, B, C], item: B):
|
def test_get_returns_a_value_if_it_exists(self, repo: CRUDRepoImpl[A, B, C], item: B):
|
||||||
assert repo.get(1) == item
|
assert repo.get(1) == item
|
||||||
|
|
||||||
def test_update(self, repo: CRUDRepo[A, B, C], transform: Callable[[B], B], item: B, expected_transformed_value: B):
|
def test_update(
|
||||||
|
self, repo: CRUDRepoImpl[A, B, C], transform: Callable[[B], B], item: B, expected_transformed_value: B
|
||||||
|
):
|
||||||
assert repo.update(1, transform) == expected_transformed_value
|
assert repo.update(1, transform) == expected_transformed_value
|
||||||
|
|
||||||
def test_delete(self, repo: CRUDRepo[A, B, C], item: B):
|
def test_delete(self, repo: CRUDRepoImpl[A, B, C], item: B):
|
||||||
assert repo.get(1) is not None
|
assert repo.get(1) is not None
|
||||||
repo.delete(1, None)
|
repo.delete(1, None)
|
||||||
assert repo.get(1) is None
|
assert repo.get(1) is None
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from src.infra.users import UserModel
|
from src.infra.users import UserModel
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, Session, joinedload
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, Session, joinedload
|
||||||
from src.infra.db import Base, CRUDRepo
|
from src.infra.db import Base, CRUDRepoImpl
|
||||||
from sqlalchemy import Engine, ForeignKey, CheckConstraint, DateTime, func, UniqueConstraint, select
|
from sqlalchemy import Engine, ForeignKey, CheckConstraint, DateTime, func, UniqueConstraint, select
|
||||||
from src.services.subscription import (
|
from src.services.subscription import (
|
||||||
SubscriptionRepo,
|
SubscriptionRepo,
|
||||||
|
|
@ -62,7 +62,7 @@ def mutate_subscription(db_sub: SubscriptionModel, sub: Subscription):
|
||||||
db_sub.sequence_notified = sub.sequence_notified
|
db_sub.sequence_notified = sub.sequence_notified
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionRepoImpl(SubscriptionRepo, CRUDRepo[SubscriptionModel, Subscription, SubscriptionCreateParams]):
|
class SubscriptionRepoImpl(SubscriptionRepo, CRUDRepoImpl[SubscriptionModel, Subscription, SubscriptionCreateParams]):
|
||||||
def __init__(self, db: Engine):
|
def __init__(self, db: Engine):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
db, SubscriptionModel, model_to_subscription, mutate_subscription, lambda u: SubscriptionModel(**asdict(u))
|
db, SubscriptionModel, model_to_subscription, mutate_subscription, lambda u: SubscriptionModel(**asdict(u))
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from src.config.email import EmailAddress
|
||||||
from sqlalchemy import String, VARCHAR, Engine, select
|
from sqlalchemy import String, VARCHAR, Engine, select
|
||||||
from sqlalchemy.orm import mapped_column, Mapped, Session, relationship
|
from sqlalchemy.orm import mapped_column, Mapped, Session, relationship
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from src.infra.db import Base, CRUDRepo
|
from src.infra.db import Base, CRUDRepoImpl
|
||||||
from src.services.users.data import UserDTO, User
|
from src.services.users.data import UserDTO, User
|
||||||
from src.services.users.repo import UserRepo
|
from src.services.users.repo import UserRepo
|
||||||
|
|
||||||
|
|
@ -52,7 +52,7 @@ def make_create_user(hasher: PasswordHasher):
|
||||||
return create_user
|
return create_user
|
||||||
|
|
||||||
|
|
||||||
class UserRepoImpl(CRUDRepo[UserModel, User, UserDTO], UserRepo):
|
class UserRepoImpl(CRUDRepoImpl[UserModel, User, UserDTO], UserRepo):
|
||||||
def __init__(self, db: Engine):
|
def __init__(self, db: Engine):
|
||||||
hasher = PasswordHasher()
|
hasher = PasswordHasher()
|
||||||
super().__init__(db, UserModel, model_to_user, mutate_user, make_create_user(hasher))
|
super().__init__(db, UserModel, model_to_user, mutate_user, make_create_user(hasher))
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ from dataclasses import dataclass
|
||||||
from src.infra import get_database, UserRepoImpl
|
from src.infra import get_database, UserRepoImpl
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_talisman import Talisman
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
@ -33,6 +36,8 @@ class MyFlask(Flask):
|
||||||
def create_app(name: str, config: Config) -> Flask:
|
def create_app(name: str, config: Config) -> Flask:
|
||||||
logging.basicConfig(level=config.logging.level, format=('%(asctime)s %(levelname)s [%(name)s] %(message)s'))
|
logging.basicConfig(level=config.logging.level, format=('%(asctime)s %(levelname)s [%(name)s] %(message)s'))
|
||||||
app = MyFlask(name)
|
app = MyFlask(name)
|
||||||
|
CORS(app)
|
||||||
|
Talisman(app)
|
||||||
|
|
||||||
# Configure Services
|
# Configure Services
|
||||||
database = get_database(config.database)
|
database = get_database(config.database)
|
||||||
|
|
|
||||||
20
src/services/repo.py
Normal file
20
src/services/repo.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from typing import Callable
|
||||||
|
import abc
|
||||||
|
|
||||||
|
|
||||||
|
class CRUD[T, C](abc.ABC):
|
||||||
|
@abc.abstractmethod
|
||||||
|
def create(self, domain_item: C, verify: Callable[[T], None] | None) -> T:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get(self, item_id: int) -> T | None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def update(self, item_id: int, transform: Callable[[T], T]) -> T:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def delete(self, item_id: int, verify: Callable[[T], None] | None):
|
||||||
|
pass
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from src.infra.db import CRUD
|
from src.services.repo import CRUD
|
||||||
import abc
|
import abc
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.services.publications import (
|
from src.services.publications import (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from src.infra.db import CRUD
|
from src.services.repo import CRUD
|
||||||
import abc
|
import abc
|
||||||
from src.config.email import EmailAddress
|
from src.config.email import EmailAddress
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue