start member endpoints

This commit is contained in:
sam 2024-03-22 17:11:18 +01:00
parent cb19049b97
commit 1bd07dd771
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
11 changed files with 158 additions and 33 deletions

12
SCOPES.md Normal file
View file

@ -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

View file

@ -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")

View file

@ -1,7 +1,7 @@
from quart import Quart, request, g from quart import Quart, request, g
from quart_schema import QuartSchema, RequestSchemaValidationError 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.aio import async_session
from .db.util import validate_token from .db.util import validate_token
from .exceptions import ExpectedError from .exceptions import ExpectedError
@ -9,7 +9,8 @@ from .exceptions import ExpectedError
app = Quart(__name__) app = Quart(__name__)
QuartSchema(app) QuartSchema(app)
app.register_blueprint(users_blueprint) for bp in (users_blueprint, members_blueprint):
app.register_blueprint(bp)
@app.errorhandler(RequestSchemaValidationError) @app.errorhandler(RequestSchemaValidationError)

View file

@ -1 +1,2 @@
from .v2.users import bp as users_blueprint from .v2.users import bp as users_blueprint
from .v2.members import bp as members_blueprint

View file

@ -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/<user_ref>/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)

View file

@ -53,7 +53,7 @@ async def edit_user(data: EditUserRequest):
if data.username: if data.username:
user.username = 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 user.display_name = data.display_name
if data.is_set("bio"): if data.is_set("bio"):
user.bio = data.bio user.bio = data.bio

View file

@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): class Base(AsyncAttrs, DeclarativeBase):
pass pass

View file

@ -1,6 +1,6 @@
from typing import Any 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.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship 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_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
user: Mapped[User] = relationship(back_populates="members", lazy="immediate") user: Mapped[User] = relationship(back_populates="members", lazy="immediate")
__table_args__ = (
Index(
"members_user_name_idx", "user_id", func.lower(text("name")), unique=True
),
)

View file

@ -1,6 +1,7 @@
from pydantic import Field from pydantic import Field
from . import BaseSnowflakeModel from . import BaseSnowflakeModel
from .fields import ProfileField, FieldEntry, PronounEntry
class BaseUserModel(BaseSnowflakeModel): class BaseUserModel(BaseSnowflakeModel):
@ -8,6 +9,10 @@ class BaseUserModel(BaseSnowflakeModel):
display_name: str | None display_name: str | None
bio: str | None bio: str | None
names: list[FieldEntry] = Field(default=[])
pronouns: list[PronounEntry] = Field(default=[])
fields: list[ProfileField] = Field(default=[])
class UserModel(BaseUserModel): class UserModel(BaseUserModel):
members: list["BaseMemberModel"] = Field(default=[]) members: list["BaseMemberModel"] = Field(default=[])
@ -18,6 +23,10 @@ class BaseMemberModel(BaseSnowflakeModel):
display_name: str | None display_name: str | None
bio: str | None bio: str | None
names: list[FieldEntry] = Field(default=[])
pronouns: list[PronounEntry] = Field(default=[])
fields: list[ProfileField] = Field(default=[])
class SelfUserModel(UserModel): class SelfUserModel(UserModel):
pass pass

57
poetry.lock generated
View file

@ -668,14 +668,14 @@ tests = ["pytest", "pytz", "simplejson"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "23.2" version = "24.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
] ]
[[package]] [[package]]
@ -747,14 +747,14 @@ test = ["anyio (>=3.6.2,<4.0)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.6.3" version = "2.6.4"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"},
{file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"},
] ]
[package.dependencies] [package.dependencies]
@ -871,35 +871,35 @@ files = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.0.2" version = "8.1.1"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"},
{file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"},
] ]
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*" iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=1.3.0,<2.0" pluggy = ">=1.4,<2.0"
[package.extras] [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]] [[package]]
name = "pytest-asyncio" name = "pytest-asyncio"
version = "0.23.5.post1" version = "0.23.6"
description = "Pytest support for asyncio" description = "Pytest support for asyncio"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"},
{file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"},
] ]
[package.dependencies] [package.dependencies]
@ -973,13 +973,10 @@ description = "A Quart extension to provide schema validation"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = []
{file = "quart_schema-0.19.1-py3-none-any.whl", hash = "sha256:43f76f7ea687464c807eaf1bf18d8aa1e970ec3c3800386f01206a39de86ce9a"}, develop = false
{file = "quart_schema-0.19.1.tar.gz", hash = "sha256:2c3e6b2d838b220a80dec88c89aa32b2f080c5527ec3eb31a1b5819423753037"},
]
[package.dependencies] [package.dependencies]
pydantic = {version = ">=2", optional = true, markers = "extra == \"pydantic\""}
pyhumps = ">=1.6.1" pyhumps = ">=1.6.1"
quart = ">=0.19.0" quart = ">=0.19.0"
@ -988,20 +985,26 @@ docs = ["pydata_sphinx_theme", "sphinx-tabs (>=3.4.4)"]
msgspec = ["msgspec (>=0.18)"] msgspec = ["msgspec (>=0.18)"]
pydantic = ["pydantic (>=2)"] pydantic = ["pydantic (>=2)"]
[package.source]
type = "git"
url = "https://github.com/pgjones/quart-schema.git"
reference = "HEAD"
resolved_reference = "9f4455a1363c6edd2b23b898c554e52a9ce6d00f"
[[package]] [[package]]
name = "redis" name = "redis"
version = "5.0.2" version = "5.0.3"
description = "Python client for Redis database and key-value store" description = "Python client for Redis database and key-value store"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "redis-5.0.2-py3-none-any.whl", hash = "sha256:4caa8e1fcb6f3c0ef28dba99535101d80934b7d4cd541bbb47f4a3826ee472d1"}, {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"},
{file = "redis-5.0.2.tar.gz", hash = "sha256:3f82cc80d350e93042c8e6e7a5d0596e4dd68715babffba79492733e1f367037"}, {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"},
] ]
[package.dependencies] [package.dependencies]
async-timeout = ">=4.0.3" async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""}
[package.extras] [package.extras]
hiredis = ["hiredis (>=1.0.0)"] hiredis = ["hiredis (>=1.0.0)"]
@ -1133,14 +1136,14 @@ files = [
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.28.0" version = "0.28.1"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "uvicorn-0.28.0-py3-none-any.whl", hash = "sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1"}, {file = "uvicorn-0.28.1-py3-none-any.whl", hash = "sha256:5162f6d652f545be91b1feeaee8180774af143965ca9dc8a47ff1dc6bafa4ad5"},
{file = "uvicorn-0.28.0.tar.gz", hash = "sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067"}, {file = "uvicorn-0.28.1.tar.gz", hash = "sha256:08103e79d546b6cf20f67c7e5e434d2cf500a6e29b28773e407250c54fc4fa3c"},
] ]
[package.dependencies] [package.dependencies]
@ -1210,4 +1213,4 @@ h11 = ">=0.9.0,<1"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "0caa52b71528d21cd36a944e2e1d217c692aedb9bc41f8813b9daa2f4a8bed0f" content-hash = "c4719965b47a0a85c480b6a26e0c7fd84d61e281a4bb012049fac9c61cd813df"

View file

@ -12,7 +12,10 @@ sqlalchemy = { extras = ["asyncio"], version = "^2.0.28" }
psycopg = "^3.1.18" psycopg = "^3.1.18"
celery = { extras = ["redis"], version = "^5.3.6" } celery = { extras = ["redis"], version = "^5.3.6" }
quart = "^0.19.4" 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" pydantic = "^2.6.3"
itsdangerous = "^2.1.2" itsdangerous = "^2.1.2"
uvicorn = "^0.28.0" uvicorn = "^0.28.0"