Skip to content

Commit 2bc34be

Browse files
authored
Merge branch 'tiangolo:master' into master
2 parents ddb7887 + 68b86b1 commit 2bc34be

36 files changed

Lines changed: 1636 additions & 603 deletions

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,h
1313
SECRET_KEY=changethis
1414
FIRST_SUPERUSER=admin@example.com
1515
FIRST_SUPERUSER_PASSWORD=changethis
16-
USERS_OPEN_REGISTRATION=False
16+
USERS_OPEN_REGISTRATION=True
1717

1818
# Emails
1919
SMTP_HOST=

.github/workflows/playwright.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Playwright Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
types:
9+
- opened
10+
- synchronize
11+
workflow_dispatch:
12+
inputs:
13+
debug_enabled:
14+
description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)'
15+
required: false
16+
default: 'false'
17+
18+
jobs:
19+
20+
test:
21+
timeout-minutes: 60
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: actions/setup-node@v4
26+
with:
27+
node-version: lts/*
28+
- uses: actions/setup-python@v5
29+
with:
30+
python-version: '3.10'
31+
- name: Setup tmate session
32+
uses: mxschmitt/action-tmate@v3
33+
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
34+
with:
35+
limit-access-to-actor: true
36+
- name: Install dependencies
37+
run: npm ci
38+
working-directory: frontend
39+
- name: Install Playwright Browsers
40+
run: npx playwright install --with-deps
41+
working-directory: frontend
42+
- run: docker compose build
43+
- run: docker compose down -v --remove-orphans
44+
- run: docker compose up -d
45+
- name: Run Playwright tests
46+
run: npx playwright test
47+
working-directory: frontend
48+
- run: docker compose down -v --remove-orphans
49+
- uses: actions/upload-artifact@v4
50+
if: always()
51+
with:
52+
name: playwright-report
53+
path: frontend/playwright-report/
54+
retention-days: 30
55+
56+
# https://github.com/marketplace/actions/alls-green#why
57+
e2e-alls-green: # This job does nothing and is only used for the branch protection
58+
if: always()
59+
needs:
60+
- test
61+
runs-on: ubuntu-latest
62+
steps:
63+
- name: Decide whether the needed jobs succeeded or failed
64+
uses: re-actors/alls-green@release/v1
65+
with:
66+
jobs: ${{ toJSON(needs) }}

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
.vscode
2+
node_modules/
3+
/test-results/
4+
/playwright-report/
5+
/blob-report/
6+
/playwright/.cache/
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Edit replace id integers in all models to use UUID instead
2+
3+
Revision ID: d98dd8ec85a3
4+
Revises: 9c0a54914c78
5+
Create Date: 2024-07-19 04:08:04.000976
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
from sqlalchemy.dialects import postgresql
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision = 'd98dd8ec85a3'
16+
down_revision = '9c0a54914c78'
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade():
22+
# Ensure uuid-ossp extension is available
23+
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
24+
25+
# Create a new UUID column with a default UUID value
26+
op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()')))
27+
op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()')))
28+
op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True))
29+
30+
# Populate the new columns with UUIDs
31+
op.execute('UPDATE "user" SET new_id = uuid_generate_v4()')
32+
op.execute('UPDATE item SET new_id = uuid_generate_v4()')
33+
op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)')
34+
35+
# Set the new_id as not nullable
36+
op.alter_column('user', 'new_id', nullable=False)
37+
op.alter_column('item', 'new_id', nullable=False)
38+
39+
# Drop old columns and rename new columns
40+
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
41+
op.drop_column('item', 'owner_id')
42+
op.alter_column('item', 'new_owner_id', new_column_name='owner_id')
43+
44+
op.drop_column('user', 'id')
45+
op.alter_column('user', 'new_id', new_column_name='id')
46+
47+
op.drop_column('item', 'id')
48+
op.alter_column('item', 'new_id', new_column_name='id')
49+
50+
# Create primary key constraint
51+
op.create_primary_key('user_pkey', 'user', ['id'])
52+
op.create_primary_key('item_pkey', 'item', ['id'])
53+
54+
# Recreate foreign key constraint
55+
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
56+
57+
def downgrade():
58+
# Reverse the upgrade process
59+
op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True))
60+
op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True))
61+
op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True))
62+
63+
# Populate the old columns with default values
64+
# Generate sequences for the integer IDs if not exist
65+
op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id')
66+
op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id')
67+
68+
op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)')
69+
op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)')
70+
71+
op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')')
72+
op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)')
73+
74+
# Drop new columns and rename old columns back
75+
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
76+
op.drop_column('item', 'owner_id')
77+
op.alter_column('item', 'old_owner_id', new_column_name='owner_id')
78+
79+
op.drop_column('user', 'id')
80+
op.alter_column('user', 'old_id', new_column_name='id')
81+
82+
op.drop_column('item', 'id')
83+
op.alter_column('item', 'old_id', new_column_name='id')
84+
85+
# Create primary key constraint
86+
op.create_primary_key('user_pkey', 'user', ['id'])
87+
op.create_primary_key('item_pkey', 'item', ['id'])
88+
89+
# Recreate foreign key constraint
90+
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])

backend/app/api/routes/items.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import Any
23

34
from fastapi import APIRouter, HTTPException
@@ -41,7 +42,7 @@ def read_items(
4142

4243

4344
@router.get("/{id}", response_model=ItemPublic)
44-
def read_item(session: SessionDep, current_user: CurrentUser, id: int) -> Any:
45+
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
4546
"""
4647
Get item by ID.
4748
"""
@@ -69,7 +70,11 @@ def create_item(
6970

7071
@router.put("/{id}", response_model=ItemPublic)
7172
def update_item(
72-
*, session: SessionDep, current_user: CurrentUser, id: int, item_in: ItemUpdate
73+
*,
74+
session: SessionDep,
75+
current_user: CurrentUser,
76+
id: uuid.UUID,
77+
item_in: ItemUpdate,
7378
) -> Any:
7479
"""
7580
Update an item.
@@ -88,7 +93,9 @@ def update_item(
8893

8994

9095
@router.delete("/{id}")
91-
def delete_item(session: SessionDep, current_user: CurrentUser, id: int) -> Message:
96+
def delete_item(
97+
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
98+
) -> Message:
9299
"""
93100
Delete an item.
94101
"""

backend/app/api/routes/users.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import Any
23

34
from fastapi import APIRouter, Depends, HTTPException
@@ -163,7 +164,7 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
163164

164165
@router.get("/{user_id}", response_model=UserPublic)
165166
def read_user_by_id(
166-
user_id: int, session: SessionDep, current_user: CurrentUser
167+
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
167168
) -> Any:
168169
"""
169170
Get a specific user by id.
@@ -187,7 +188,7 @@ def read_user_by_id(
187188
def update_user(
188189
*,
189190
session: SessionDep,
190-
user_id: int,
191+
user_id: uuid.UUID,
191192
user_in: UserUpdate,
192193
) -> Any:
193194
"""
@@ -213,7 +214,7 @@ def update_user(
213214

214215
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
215216
def delete_user(
216-
session: SessionDep, current_user: CurrentUser, user_id: int
217+
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
217218
) -> Message:
218219
"""
219220
Delete a user.

backend/app/core/security.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timedelta
1+
from datetime import datetime, timedelta, timezone
22
from typing import Any
33

44
import jwt
@@ -13,7 +13,7 @@
1313

1414

1515
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
16-
expire = datetime.utcnow() + expires_delta
16+
expire = datetime.now(timezone.utc) + expires_delta
1717
to_encode = {"exp": expire, "sub": str(subject)}
1818
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
1919
return encoded_jwt

backend/app/crud.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import Any
23

34
from sqlmodel import Session, select
@@ -45,7 +46,7 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
4546
return db_user
4647

4748

48-
def create_item(*, session: Session, item_in: ItemCreate, owner_id: int) -> Item:
49+
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
4950
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
5051
session.add(db_item)
5152
session.commit()

backend/app/models.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
from pydantic import EmailStr
24
from sqlmodel import Field, Relationship, SQLModel
35

@@ -39,14 +41,14 @@ class UpdatePassword(SQLModel):
3941

4042
# Database model, database table inferred from class name
4143
class User(UserBase, table=True):
42-
id: int | None = Field(default=None, primary_key=True)
44+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
4345
hashed_password: str
4446
items: list["Item"] = Relationship(back_populates="owner")
4547

4648

4749
# Properties to return via API, id is always required
4850
class UserPublic(UserBase):
49-
id: int
51+
id: uuid.UUID
5052

5153

5254
class UsersPublic(SQLModel):
@@ -72,16 +74,16 @@ class ItemUpdate(ItemBase):
7274

7375
# Database model, database table inferred from class name
7476
class Item(ItemBase, table=True):
75-
id: int | None = Field(default=None, primary_key=True)
77+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
7678
title: str = Field(max_length=255)
77-
owner_id: int | None = Field(default=None, foreign_key="user.id", nullable=False)
79+
owner_id: uuid.UUID = Field(foreign_key="user.id", nullable=False)
7880
owner: User | None = Relationship(back_populates="items")
7981

8082

8183
# Properties to return via API, id is always required
8284
class ItemPublic(ItemBase):
83-
id: int
84-
owner_id: int
85+
id: uuid.UUID
86+
owner_id: uuid.UUID
8587

8688

8789
class ItemsPublic(SQLModel):
@@ -102,7 +104,7 @@ class Token(SQLModel):
102104

103105
# Contents of JWT token
104106
class TokenPayload(SQLModel):
105-
sub: int | None = None
107+
sub: str | None = None
106108

107109

108110
class NewPassword(SQLModel):

backend/app/tests/api/routes/test_items.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
from fastapi.testclient import TestClient
24
from sqlmodel import Session
35

@@ -34,15 +36,15 @@ def test_read_item(
3436
content = response.json()
3537
assert content["title"] == item.title
3638
assert content["description"] == item.description
37-
assert content["id"] == item.id
38-
assert content["owner_id"] == item.owner_id
39+
assert content["id"] == str(item.id)
40+
assert content["owner_id"] == str(item.owner_id)
3941

4042

4143
def test_read_item_not_found(
4244
client: TestClient, superuser_token_headers: dict[str, str]
4345
) -> None:
4446
response = client.get(
45-
f"{settings.API_V1_STR}/items/999",
47+
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
4648
headers=superuser_token_headers,
4749
)
4850
assert response.status_code == 404
@@ -91,16 +93,16 @@ def test_update_item(
9193
content = response.json()
9294
assert content["title"] == data["title"]
9395
assert content["description"] == data["description"]
94-
assert content["id"] == item.id
95-
assert content["owner_id"] == item.owner_id
96+
assert content["id"] == str(item.id)
97+
assert content["owner_id"] == str(item.owner_id)
9698

9799

98100
def test_update_item_not_found(
99101
client: TestClient, superuser_token_headers: dict[str, str]
100102
) -> None:
101103
data = {"title": "Updated title", "description": "Updated description"}
102104
response = client.put(
103-
f"{settings.API_V1_STR}/items/999",
105+
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
104106
headers=superuser_token_headers,
105107
json=data,
106108
)
@@ -141,7 +143,7 @@ def test_delete_item_not_found(
141143
client: TestClient, superuser_token_headers: dict[str, str]
142144
) -> None:
143145
response = client.delete(
144-
f"{settings.API_V1_STR}/items/999",
146+
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
145147
headers=superuser_token_headers,
146148
)
147149
assert response.status_code == 404

0 commit comments

Comments
 (0)