start member endpoints
This commit is contained in:
parent
cb19049b97
commit
1bd07dd771
11 changed files with 158 additions and 33 deletions
12
SCOPES.md
Normal file
12
SCOPES.md
Normal 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
|
31
alembic/versions/1711032729_add_unique_index_to_members.py
Normal file
31
alembic/versions/1711032729_add_unique_index_to_members.py
Normal 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")
|
|
@ -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)
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
from .v2.users import bp as users_blueprint
|
||||
from .v2.members import bp as members_blueprint
|
||||
|
|
58
foxnouns/blueprints/v2/members.py
Normal file
58
foxnouns/blueprints/v2/members.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
pass
|
||||
|
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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
57
poetry.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue