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 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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
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:
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -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
57
poetry.lock
generated
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue