Skip to content

Commit dbd30b6

Browse files
authored
Merge pull request #21 from fmipython/workshop3
Add workshop3 task
2 parents dcbb45c + 6b6dfc5 commit dbd30b6

File tree

12 files changed

+1477
-0
lines changed

12 files changed

+1477
-0
lines changed

workshops/workshop3/.gitignore

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
.Python
7+
*.egg-info/
8+
dist/
9+
build/
10+
*.egg
11+
12+
# Virtual Environment
13+
venv/
14+
env/
15+
ENV/
16+
.venv/
17+
18+
# IDEs
19+
.vscode/
20+
.idea/
21+
*.swp
22+
*.swo
23+
*~
24+
25+
# Databases
26+
*.db
27+
*.sqlite
28+
*.sqlite3
29+
flask_workshop.db
30+
fastapi_workshop.db
31+
32+
# Testing
33+
.pytest_cache/
34+
.coverage
35+
htmlcov/
36+
.tox/
37+
38+
# OS
39+
.DS_Store
40+
Thumbs.db
41+
42+
# Logs
43+
*.log

workshops/workshop3/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Workshop 3 - REST API with Flask and FastAPI
2+
3+
## Накратко
4+
5+
* REST API
6+
* CRUD операции с база данни
7+
* Authentication
8+
* Flask vs FastAPI
9+
* Тестове
10+
11+
## Какво ви е дадено
12+
13+
* [db.py](db.py) и [db_models.py](db_models.py) - Модели и операции с SQLite база данни
14+
* [flask_app.py](flask_app.py) и [fastapi_app.py](fastapi_app.py) - Започнати JSON REST API чрез Flask и FastAPI съответно.
15+
* [flast_tests.py](flask_tests.py) и [fastapi_tests.py](fastapi_tests.py) - API тестове
16+
17+
## Какво се изисква от вас?
18+
19+
Имплементирани са само endpoint-и за **потребители**. Трябва да довършите с такива за **публикации**:
20+
* `POST /publications/` - Създаване на публикация (само за автентикирани потребители)
21+
* 401 при липса на правилна автентикация
22+
* 400 при липсващи полета/тяло на заявката
23+
* 201 при успешно създаване (и да се върне създадената публикация)
24+
* `GET /publications/{publication_id}/` - Връщане на публикация по ID
25+
* 404 ако публикацията не съществува
26+
* 200 при успешно връщане на публикацията
27+
* `GET /publications/` - Връщане на списък с всички
28+
* параметър `skip` (по подразбиране 0) - брой публикации за пропускане
29+
* параметър `limit` (по подразбиране 100) - максимален брой публикации за връщане
30+
* параметър `owner_id` (по избор) - филтриране по собственик
31+
* `PUT /publications/{publication_id}/` - Актуализиране на публикация (само от собственика или администратор)
32+
* 401 при липса на автентикация
33+
* 404 ако публикацията не съществува
34+
* 403 при опит за достъп от друг потребител, който няма право
35+
* 400 при липсващи полета/тяло на заявката
36+
* 200 при успешно актуализиране (и да се върне актуализираната публикация)
37+
* `DELETE /publications/{publication_id}/` - Изтриване на публикация (само от собственика или администратор)
38+
* 401 при липса на автентикация
39+
* 404 ако публикацията не съществува
40+
* 403 при опит за достъп от друг потребител, който няма право
41+
* 200 при успешно изтриване (и да се върне съобщение за успех)
42+
43+
Напишете подходящи тестове за всички изброени случаи. Може да подходите по test-driven подход (първо пишете тестовете, след това кода) или да довършите първо кода и после да напишете тестовете - по ваш избор. Ако сметнете, че нямаше много време, напишете само по един тест на ендпойнт, поне за начало.
44+
45+
## Особености
46+
47+
* За автентикация използвайте вече наличния код (Basic Auth). Не е нужно да имплементирате нова система. За вашите проекти е пропоръчително да разучите по-сигурен начин на автентикация (напр. OAuth2, JWT и т.н.)
48+
* За улеснение имате направени декоратори и помощни сериализиращи функции (за flask app), както и Pydantic модели и Depends функции (за FastAPI app)
49+
* Обърнете внимание, че при първото пускане на сървърите се създават таблиците в базата данни и се добавя един хардкоднат администраторски потребител, с който можете да си тествате (име `test_admin`, парола `testing123`)
50+
* Разгледайте съществуващите ендпойнти, както и методите на `DatabaseService`, преди да започнете работа
51+
52+
## Полезни ресурси
53+
54+
- [Flask Documentation](https://flask.palletsprojects.com/)
55+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
56+
- [Postman](https://www.postman.com/) - Инструмент за правене на заявки към API
57+
- [Postman VSCode extension](https://marketplace.visualstudio.com/items?itemName=Postman.postman-for-vscode)
58+
- [DB viewer VSCode extension](https://marketplace.visualstudio.com/items?itemName=MJStudio.db-viewer)
59+
- [Pytest Documentation](https://docs.pytest.org/)
60+
- [Pydantic Documentation](https://docs.pydantic.dev/)
61+

workshops/workshop3/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""
2+
Pytest configuration for async tests
3+
"""
4+
5+
import pytest
6+
7+
pytest_plugins = ["pytest_asyncio"]
8+
9+
10+
def pytest_configure(config):
11+
config.option.asyncio_mode = "auto"

workshops/workshop3/db.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
"""
2+
Async Database Module with CRUD Operations
3+
Reusable by both Flask and FastAPI applications
4+
"""
5+
6+
import hashlib
7+
from typing import Sequence
8+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
9+
from sqlalchemy.future import select
10+
from sqlalchemy import delete
11+
from db_models import Base, User, Publication
12+
13+
14+
class DatabaseService:
15+
"""Async database manager for CRUD operations"""
16+
17+
def __init__(self, database_url: str = "sqlite+aiosqlite:///./workshop.db"):
18+
"""
19+
Initialize database connection
20+
Args:
21+
database_url: SQLAlchemy database URL (must support async)
22+
"""
23+
self.engine = create_async_engine(database_url, echo=False)
24+
self.async_session = async_sessionmaker(
25+
self.engine, class_=AsyncSession, expire_on_commit=False
26+
)
27+
28+
async def create_tables(self):
29+
"""Create all tables defined in models"""
30+
async with self.engine.begin() as conn:
31+
await conn.run_sync(Base.metadata.create_all)
32+
33+
# preload database with test admin user
34+
await self._preload_data()
35+
36+
async def drop_tables(self):
37+
"""Drop all tables - useful for testing"""
38+
async with self.engine.begin() as conn:
39+
await conn.run_sync(Base.metadata.drop_all)
40+
41+
async def close(self):
42+
"""Close database connection"""
43+
await self.engine.dispose()
44+
45+
async def _preload_data(self):
46+
"""Preload database with initial data"""
47+
existing_user = await self.get_user_by_username("test_admin")
48+
if not existing_user:
49+
await self.create_user(
50+
username="test_admin",
51+
email="test_admin@example.com",
52+
password="testing123",
53+
is_admin=True,
54+
)
55+
print("✓ Preloaded database with test_admin user")
56+
57+
# ==================== USER CRUD OPERATIONS ====================
58+
59+
async def create_user(
60+
self, username: str, email: str, password: str, is_admin: bool = False
61+
) -> User:
62+
"""
63+
Create a new user
64+
Args:
65+
username: Unique username
66+
email: User email
67+
password: Plain text password (will be hashed)
68+
is_admin: Whether the user has admin privileges (default: False)
69+
Returns:
70+
Created User object
71+
"""
72+
async with self.async_session() as session:
73+
# Simple password hashing (for demo purposes - use bcrypt/passlib in production)
74+
password_hash = hashlib.sha256(password.encode()).hexdigest()
75+
76+
user = User(
77+
username=username,
78+
email=email,
79+
password_hash=password_hash,
80+
is_admin=is_admin,
81+
)
82+
session.add(user)
83+
await session.commit()
84+
await session.refresh(user)
85+
return user
86+
87+
async def get_user(self, user_id: int) -> User | None:
88+
"""Get user by ID"""
89+
async with self.async_session() as session:
90+
result = await session.execute(select(User).where(User.id == user_id))
91+
return result.scalar_one_or_none()
92+
93+
async def get_user_by_username(self, username: str) -> User | None:
94+
"""Get user by username"""
95+
async with self.async_session() as session:
96+
result = await session.execute(
97+
select(User).where(User.username == username)
98+
)
99+
return result.scalar_one_or_none()
100+
101+
async def get_user_by_email(self, email: str) -> User | None:
102+
"""Get user by email"""
103+
async with self.async_session() as session:
104+
result = await session.execute(select(User).where(User.email == email))
105+
return result.scalar_one_or_none()
106+
107+
async def get_all_users(self, skip: int = 0, limit: int = 100) -> Sequence[User]:
108+
"""Get all users with pagination"""
109+
async with self.async_session() as session:
110+
result = await session.execute(select(User).offset(skip).limit(limit))
111+
return result.scalars().all()
112+
113+
async def update_user(self, user_id: int, **kwargs) -> User | None:
114+
"""
115+
Update user fields
116+
Args:
117+
user_id: User ID to update
118+
**kwargs: Fields to update (username, email, password)
119+
Returns:
120+
Updated User object or None if not found
121+
"""
122+
async with self.async_session() as session:
123+
# Hash password if provided
124+
if "password" in kwargs:
125+
kwargs["password_hash"] = hashlib.sha256(
126+
kwargs.pop("password").encode()
127+
).hexdigest()
128+
129+
result = await session.execute(select(User).where(User.id == user_id))
130+
user = result.scalar_one_or_none()
131+
132+
if user:
133+
for key, value in kwargs.items():
134+
setattr(user, key, value)
135+
await session.commit()
136+
await session.refresh(user)
137+
return user
138+
139+
async def delete_user(self, user_id: int) -> bool:
140+
"""
141+
Delete user by ID
142+
Returns:
143+
True if deleted, False if not found
144+
"""
145+
async with self.async_session() as session:
146+
result = await session.execute(delete(User).where(User.id == user_id))
147+
await session.commit()
148+
return result.rowcount > 0 # type: ignore
149+
150+
async def authenticate_user(self, username: str, password: str) -> User | None:
151+
"""
152+
Authenticate user by username and password
153+
Returns:
154+
User object if credentials are valid, None otherwise
155+
"""
156+
async with self.async_session() as session:
157+
password_hash = hashlib.sha256(password.encode()).hexdigest()
158+
result = await session.execute(
159+
select(User).where(
160+
User.username == username, User.password_hash == password_hash
161+
)
162+
)
163+
return result.scalar_one_or_none()
164+
165+
# ==================== PUBLICATION CRUD OPERATIONS ====================
166+
167+
async def create_publication(
168+
self,
169+
title: str,
170+
content: str,
171+
owner_id: int,
172+
) -> Publication:
173+
"""
174+
Create a new publication
175+
Args:
176+
title: Publication title
177+
content: Publication content
178+
owner_id: ID of the user who owns this publication
179+
Returns:
180+
Created Publication object
181+
"""
182+
async with self.async_session() as session:
183+
publication = Publication(
184+
title=title,
185+
content=content,
186+
owner_id=owner_id,
187+
)
188+
session.add(publication)
189+
await session.commit()
190+
await session.refresh(publication)
191+
return publication
192+
193+
async def get_publication(self, publication_id: int) -> Publication | None:
194+
"""Get publication by ID"""
195+
async with self.async_session() as session:
196+
result = await session.execute(
197+
select(Publication).where(Publication.id == publication_id)
198+
)
199+
return result.scalar_one_or_none()
200+
201+
async def get_all_publications(
202+
self, skip: int = 0, limit: int = 100
203+
) -> Sequence[Publication]:
204+
"""Get all publications with pagination"""
205+
async with self.async_session() as session:
206+
result = await session.execute(
207+
select(Publication).offset(skip).limit(limit)
208+
)
209+
return result.scalars().all()
210+
211+
async def get_publications_by_owner(
212+
self, owner_id: int, skip: int = 0, limit: int = 100
213+
) -> Sequence[Publication]:
214+
"""Get all publications owned by a specific user"""
215+
async with self.async_session() as session:
216+
result = await session.execute(
217+
select(Publication)
218+
.where(Publication.owner_id == owner_id)
219+
.offset(skip)
220+
.limit(limit)
221+
)
222+
return result.scalars().all()
223+
224+
async def update_publication(
225+
self, publication_id: int, **kwargs
226+
) -> Publication | None:
227+
"""
228+
Update publication fields
229+
Args:
230+
publication_id: Publication ID to update
231+
**kwargs: Fields to update (name, description, price, quantity)
232+
Returns:
233+
Updated Publication object or None if not found
234+
"""
235+
async with self.async_session() as session:
236+
result = await session.execute(
237+
select(Publication).where(Publication.id == publication_id)
238+
)
239+
publication = result.scalar_one_or_none()
240+
241+
if publication:
242+
for key, value in kwargs.items():
243+
if hasattr(publication, key):
244+
setattr(publication, key, value)
245+
await session.commit()
246+
await session.refresh(publication)
247+
return publication
248+
249+
async def delete_publication(self, publication_id: int) -> bool:
250+
"""
251+
Delete publication by ID
252+
Returns:
253+
True if deleted, False if not found
254+
"""
255+
async with self.async_session() as session:
256+
result = await session.execute(
257+
delete(Publication).where(Publication.id == publication_id)
258+
)
259+
await session.commit()
260+
return result.rowcount > 0 # type: ignore

0 commit comments

Comments
 (0)