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_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)

View file

@ -1 +1,2 @@
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:
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

View file

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

View file

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

View file

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

57
poetry.lock generated
View file

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

View file

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