diff --git a/SCOPES.md b/SCOPES.md new file mode 100644 index 0000000..ee377a6 --- /dev/null +++ b/SCOPES.md @@ -0,0 +1,12 @@ +- `user` + - `user.read_hidden`: read current user's hidden data. + This includes data such as timezone and whether the user's member list is hidden. + - `user.read_privileged`: read privileged user data such as authentication methods + - `user.update`: update current user. This scope cannot update privileged data. This scope implies `user.read_hidden`. +- `member` + - `member.read`: read member list, even if it's hidden, including hidden members. + - `member.update`: update and delete existing members. + While `update` and `delete` could be separate, that might lull users into a false sense of security, + as it would still be possible to clear members and scramble their names, + which would be equivalent to `delete` anyway. + - `member.create`: create new members diff --git a/alembic/versions/1711032729_add_unique_index_to_members.py b/alembic/versions/1711032729_add_unique_index_to_members.py new file mode 100644 index 0000000..782af20 --- /dev/null +++ b/alembic/versions/1711032729_add_unique_index_to_members.py @@ -0,0 +1,31 @@ +"""Add unique index to members + +Revision ID: a000d800f45f +Revises: 17cc8cb77be5 +Create Date: 2024-03-21 15:52:09.403257 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "a000d800f45f" +down_revision: Union[str, None] = "17cc8cb77be5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "members_user_name_idx", + "members", + ["user_id", sa.text("lower(name)")], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("members_user_name_idx", table_name="members") diff --git a/foxnouns/app.py b/foxnouns/app.py index 25745eb..94e5371 100644 --- a/foxnouns/app.py +++ b/foxnouns/app.py @@ -1,7 +1,7 @@ from quart import Quart, request, g from quart_schema import QuartSchema, RequestSchemaValidationError -from .blueprints import users_blueprint +from .blueprints import users_blueprint, members_blueprint from .db.aio import async_session from .db.util import validate_token from .exceptions import ExpectedError @@ -9,7 +9,8 @@ from .exceptions import ExpectedError app = Quart(__name__) QuartSchema(app) -app.register_blueprint(users_blueprint) +for bp in (users_blueprint, members_blueprint): + app.register_blueprint(bp) @app.errorhandler(RequestSchemaValidationError) diff --git a/foxnouns/blueprints/__init__.py b/foxnouns/blueprints/__init__.py index a42e65b..56fa71e 100644 --- a/foxnouns/blueprints/__init__.py +++ b/foxnouns/blueprints/__init__.py @@ -1 +1,2 @@ from .v2.users import bp as users_blueprint +from .v2.members import bp as members_blueprint diff --git a/foxnouns/blueprints/v2/members.py b/foxnouns/blueprints/v2/members.py new file mode 100644 index 0000000..63778f8 --- /dev/null +++ b/foxnouns/blueprints/v2/members.py @@ -0,0 +1,58 @@ +from pydantic import Field +from quart import Blueprint, g +from quart_schema import validate_response, validate_request +from sqlalchemy import select + +from foxnouns.auth import require_auth +from foxnouns.db import Member +from foxnouns.db.aio import async_session +from foxnouns.db.util import user_from_ref, is_self +from foxnouns.exceptions import NotFoundError, ErrorCode +from foxnouns.models import BasePatchModel +from foxnouns.models.member import FullMemberModel +from foxnouns.models.fields import ProfileField, FieldEntry, PronounEntry +from foxnouns.settings import BASE_DOMAIN + +bp = Blueprint("members_v2", __name__) + + +@bp.get("/api/v2/users//members", host=BASE_DOMAIN) +@validate_response(list[FullMemberModel], 200) +async def get_members(user_ref: str): + async with async_session() as session: + user = await user_from_ref(session, user_ref) + if not user: + raise NotFoundError("User not found", type=ErrorCode.UserNotFound) + + return [FullMemberModel.model_validate(m) for m in user.members] + + +class MemberPostData(BasePatchModel): + name: str = Field(min_length=1, max_length=100) # TODO: validate member names more + bio: str | None = Field(max_length=1024, default=None) + + names: list[FieldEntry] = Field(default=[]) + pronouns: list[PronounEntry] = Field(default=[]) + fields: list[ProfileField] = Field(default=[]) + + +@bp.post("/api/v2/members", host=BASE_DOMAIN) +@require_auth(scope="member.create") +@validate_request(MemberPostData) +@validate_response(FullMemberModel, 200) +async def create_member(data: MemberPostData): + async with async_session() as session: + member = Member( + user_id=g.user.id, + name=data.name, + bio=data.bio, + names=[e.model_dump() for e in data.names], + pronouns=[e.model_dump() for e in data.pronouns], + fields=[e.model_dump() for e in data.fields], + ) + + session.add(member) + await session.commit() + await member.awaitable_attrs.user + + return FullMemberModel.model_validate(member) diff --git a/foxnouns/blueprints/v2/users.py b/foxnouns/blueprints/v2/users.py index 7d3d890..5ceb50f 100644 --- a/foxnouns/blueprints/v2/users.py +++ b/foxnouns/blueprints/v2/users.py @@ -53,7 +53,7 @@ async def edit_user(data: EditUserRequest): if data.username: user.username = data.username - if "display_name" in data.model_fields_set: + if data.is_set("display_name"): user.display_name = data.display_name if data.is_set("bio"): user.bio = data.bio diff --git a/foxnouns/db/base.py b/foxnouns/db/base.py index fa2b68a..7d80ade 100644 --- a/foxnouns/db/base.py +++ b/foxnouns/db/base.py @@ -1,5 +1,6 @@ +from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase -class Base(DeclarativeBase): +class Base(AsyncAttrs, DeclarativeBase): pass diff --git a/foxnouns/db/member.py b/foxnouns/db/member.py index 164157f..3e27621 100644 --- a/foxnouns/db/member.py +++ b/foxnouns/db/member.py @@ -1,6 +1,6 @@ from typing import Any -from sqlalchemy import Text, BigInteger, ForeignKey +from sqlalchemy import Text, BigInteger, ForeignKey, Index, func, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -26,3 +26,9 @@ class Member(Base): user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user: Mapped[User] = relationship(back_populates="members", lazy="immediate") + + __table_args__ = ( + Index( + "members_user_name_idx", "user_id", func.lower(text("name")), unique=True + ), + ) diff --git a/foxnouns/models/user.py b/foxnouns/models/user.py index f5ad505..8e896d5 100644 --- a/foxnouns/models/user.py +++ b/foxnouns/models/user.py @@ -1,6 +1,7 @@ from pydantic import Field from . import BaseSnowflakeModel +from .fields import ProfileField, FieldEntry, PronounEntry class BaseUserModel(BaseSnowflakeModel): @@ -8,6 +9,10 @@ class BaseUserModel(BaseSnowflakeModel): display_name: str | None bio: str | None + names: list[FieldEntry] = Field(default=[]) + pronouns: list[PronounEntry] = Field(default=[]) + fields: list[ProfileField] = Field(default=[]) + class UserModel(BaseUserModel): members: list["BaseMemberModel"] = Field(default=[]) @@ -18,6 +23,10 @@ class BaseMemberModel(BaseSnowflakeModel): display_name: str | None bio: str | None + names: list[FieldEntry] = Field(default=[]) + pronouns: list[PronounEntry] = Field(default=[]) + fields: list[ProfileField] = Field(default=[]) + class SelfUserModel(UserModel): pass diff --git a/poetry.lock b/poetry.lock index c6ec4c1..b1682a4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -668,14 +668,14 @@ tests = ["pytest", "pytz", "simplejson"] [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] @@ -747,14 +747,14 @@ test = ["anyio (>=3.6.2,<4.0)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6 [[package]] name = "pydantic" -version = "2.6.3" +version = "2.6.4" description = "Data validation using Python type hints" category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, - {file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, + {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, + {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, ] [package.dependencies] @@ -871,35 +871,35 @@ files = [ [[package]] name = "pytest" -version = "8.0.2" +version = "8.1.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, - {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" +pluggy = ">=1.4,<2.0" [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.23.5.post1" +version = "0.23.6" description = "Pytest support for asyncio" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, - {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, + {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"}, + {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"}, ] [package.dependencies] @@ -973,13 +973,10 @@ description = "A Quart extension to provide schema validation" category = "main" optional = false python-versions = ">=3.8" -files = [ - {file = "quart_schema-0.19.1-py3-none-any.whl", hash = "sha256:43f76f7ea687464c807eaf1bf18d8aa1e970ec3c3800386f01206a39de86ce9a"}, - {file = "quart_schema-0.19.1.tar.gz", hash = "sha256:2c3e6b2d838b220a80dec88c89aa32b2f080c5527ec3eb31a1b5819423753037"}, -] +files = [] +develop = false [package.dependencies] -pydantic = {version = ">=2", optional = true, markers = "extra == \"pydantic\""} pyhumps = ">=1.6.1" quart = ">=0.19.0" @@ -988,20 +985,26 @@ docs = ["pydata_sphinx_theme", "sphinx-tabs (>=3.4.4)"] msgspec = ["msgspec (>=0.18)"] pydantic = ["pydantic (>=2)"] +[package.source] +type = "git" +url = "https://github.com/pgjones/quart-schema.git" +reference = "HEAD" +resolved_reference = "9f4455a1363c6edd2b23b898c554e52a9ce6d00f" + [[package]] name = "redis" -version = "5.0.2" +version = "5.0.3" description = "Python client for Redis database and key-value store" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "redis-5.0.2-py3-none-any.whl", hash = "sha256:4caa8e1fcb6f3c0ef28dba99535101d80934b7d4cd541bbb47f4a3826ee472d1"}, - {file = "redis-5.0.2.tar.gz", hash = "sha256:3f82cc80d350e93042c8e6e7a5d0596e4dd68715babffba79492733e1f367037"}, + {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"}, + {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, ] [package.dependencies] -async-timeout = ">=4.0.3" +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} [package.extras] hiredis = ["hiredis (>=1.0.0)"] @@ -1133,14 +1136,14 @@ files = [ [[package]] name = "uvicorn" -version = "0.28.0" +version = "0.28.1" description = "The lightning-fast ASGI server." category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.28.0-py3-none-any.whl", hash = "sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1"}, - {file = "uvicorn-0.28.0.tar.gz", hash = "sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067"}, + {file = "uvicorn-0.28.1-py3-none-any.whl", hash = "sha256:5162f6d652f545be91b1feeaee8180774af143965ca9dc8a47ff1dc6bafa4ad5"}, + {file = "uvicorn-0.28.1.tar.gz", hash = "sha256:08103e79d546b6cf20f67c7e5e434d2cf500a6e29b28773e407250c54fc4fa3c"}, ] [package.dependencies] @@ -1210,4 +1213,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "0caa52b71528d21cd36a944e2e1d217c692aedb9bc41f8813b9daa2f4a8bed0f" +content-hash = "c4719965b47a0a85c480b6a26e0c7fd84d61e281a4bb012049fac9c61cd813df" diff --git a/pyproject.toml b/pyproject.toml index 7801283..c82eaa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,10 @@ sqlalchemy = { extras = ["asyncio"], version = "^2.0.28" } psycopg = "^3.1.18" celery = { extras = ["redis"], version = "^5.3.6" } quart = "^0.19.4" -quart-schema = { extras = ["pydantic"], version = "^0.19.1" } +# Temporary until a release containing this commit is made: +# https://github.com/pgjones/quart-schema/commit/9f4455a1363c6edd2b23b898c554e52a9ce6d00f +quart-schema = { git = "https://github.com/pgjones/quart-schema.git" } +# quart-schema = { extras = ["pydantic"], version = "^0.19.1" } pydantic = "^2.6.3" itsdangerous = "^2.1.2" uvicorn = "^0.28.0"