Skip to content

Commit 001b0f5

Browse files
authored
Merge pull request #62 from kilobyteno/articles
Articles
2 parents 79931b9 + a20dd5c commit 001b0f5

32 files changed

+1724
-54
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Add article
2+
3+
Revision ID: 3c54be1b70e2
4+
Revises: 9ef2bf852502
5+
Create Date: 2024-12-29 16:58:40.321861
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '3c54be1b70e2'
16+
down_revision: Union[str, None] = '9ef2bf852502'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.create_table('articles',
24+
sa.Column('id', sa.UUID(), nullable=False),
25+
sa.Column('title', sa.String(length=255), nullable=False),
26+
sa.Column('slug', sa.String(length=255), nullable=False),
27+
sa.Column('content', sa.Text(), nullable=False),
28+
sa.Column('event_id', sa.UUID(), nullable=False),
29+
sa.Column('created_by_id', sa.UUID(), nullable=False),
30+
sa.Column('published_at', sa.DateTime(), nullable=True),
31+
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
32+
sa.Column('updated_at', sa.DateTime(), nullable=True),
33+
sa.Column('deleted_at', sa.DateTime(), nullable=True),
34+
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
35+
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
36+
sa.PrimaryKeyConstraint('id'),
37+
sa.UniqueConstraint('id')
38+
)
39+
op.create_index(op.f('ix_articles_slug'), 'articles', ['slug'], unique=False)
40+
op.create_index(op.f('ix_articles_title'), 'articles', ['title'], unique=False)
41+
op.create_unique_constraint(None, 'event_interests', ['id'])
42+
# ### end Alembic commands ###
43+
44+
45+
def downgrade() -> None:
46+
# ### commands auto generated by Alembic - please adjust! ###
47+
op.drop_constraint(None, 'event_interests', type_='unique')
48+
op.drop_index(op.f('ix_articles_title'), table_name='articles')
49+
op.drop_index(op.f('ix_articles_slug'), table_name='articles')
50+
op.drop_table('articles')
51+
# ### end Alembic commands ###

backend/app/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from app.models import base, event, event_interest, organisation, user # noqa: F401
1+
from app.models import article, base, event, event_interest, organisation, user # noqa: F401

backend/app/models/article.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, Text
2+
from sqlalchemy.orm import declarative_base, relationship
3+
4+
from app.models.base import BaseModel
5+
6+
Base = declarative_base()
7+
8+
9+
class Article(BaseModel):
10+
"""Article model for events."""
11+
12+
__tablename__ = 'articles'
13+
14+
title = Column(String(255), nullable=False, index=True)
15+
slug = Column(String(255), nullable=False, index=True)
16+
content = Column(Text, nullable=False)
17+
18+
event_id = Column(UUID(as_uuid=True), ForeignKey('events.id'), nullable=False)
19+
event = relationship('Event', back_populates='articles')
20+
21+
created_by_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
22+
created_by = relationship('User', back_populates='articles')
23+
24+
published_at = Column(DateTime, nullable=True)

backend/app/models/event.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ class Event(BaseModel):
3737
created_by = relationship('User', back_populates='events')
3838

3939
interests = relationship('EventInterest', back_populates='event')
40+
articles = relationship('Article', back_populates='event')

backend/app/models/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class User(BaseModel):
2828
organisations = relationship('Organisation', back_populates='created_by')
2929
events = relationship('Event', back_populates='created_by')
3030
event_interests = relationship('EventInterest', back_populates='user')
31+
articles = relationship('Article', back_populates='created_by')
3132

3233
__table_args__ = (UniqueConstraint('phone_code', 'phone_number', name='_phone_code_phone_number_uc'),)
3334

backend/app/v3/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter
22
from fastapi_pagination import add_pagination
33

4+
from app.v3.articles import endpoints as articles_endpoints
45
from app.v3.auth import endpoints as auth_endpoints
56
from app.v3.event_interests import endpoints as event_interests_endpoints
67
from app.v3.events import endpoints as events_endpoints
@@ -18,4 +19,5 @@
1819
router.include_router(organisations_endpoints.router, tags=['organisations'], prefix='/organisations')
1920
router.include_router(events_endpoints.router, tags=['events'], prefix='/events')
2021
router.include_router(event_interests_endpoints.router, tags=['event interests'])
22+
router.include_router(articles_endpoints.router, tags=['event articles'])
2123
router.include_router(system_endpoints.router, tags=['system'], prefix='/system') # Should be last

backend/app/v3/articles/__init__.py

Whitespace-only changes.

backend/app/v3/articles/endpoints.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from typing import List
2+
from uuid import UUID
3+
4+
from fastapi import APIRouter, Depends
5+
from fastapi.responses import JSONResponse
6+
from sqlalchemy.orm import Session
7+
8+
from app.dependencies import get_db
9+
from app.models.user import User
10+
from app.v3.articles.schemas import ArticleCreate, ArticleResponse, ArticleUpdate
11+
from app.v3.articles.service import (
12+
create_article,
13+
delete_article,
14+
get_article,
15+
get_articles,
16+
update_article,
17+
)
18+
from app.v3.auth.utils import get_current_user
19+
20+
router = APIRouter()
21+
22+
23+
@router.post(
24+
'/events/{event_id}/articles',
25+
name='EA-1',
26+
response_model=ArticleResponse,
27+
)
28+
async def post_create_article(
29+
event_id: UUID, article_data: ArticleCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
30+
) -> JSONResponse:
31+
"""Create a new article"""
32+
return create_article(db=db, event_id=event_id, current_user=current_user, article_data=article_data)
33+
34+
35+
@router.get(
36+
'/events/{event_id}/articles',
37+
name='EA-2',
38+
response_model=List[ArticleResponse],
39+
)
40+
async def get_articles_list(event_id: UUID, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> JSONResponse:
41+
"""Get published articles for an event"""
42+
return get_articles(db=db, event_id=event_id, skip=skip, limit=limit)
43+
44+
45+
@router.get(
46+
'/events/{event_id}/articles/{article_id}',
47+
name='EA-3',
48+
response_model=ArticleResponse,
49+
)
50+
async def get_article_by_id(event_id: UUID, article_id: UUID, db: Session = Depends(get_db)) -> JSONResponse:
51+
"""Get article by ID"""
52+
return get_article(db=db, event_id=event_id, article_id=article_id)
53+
54+
55+
@router.put(
56+
'/events/{event_id}/articles/{article_id}',
57+
name='EA-4',
58+
response_model=ArticleResponse,
59+
)
60+
async def put_update_article(
61+
event_id: UUID, article_id: UUID, article_data: ArticleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
62+
) -> JSONResponse:
63+
"""Update article"""
64+
return update_article(db=db, event_id=event_id, article_id=article_id, current_user=current_user, article_data=article_data)
65+
66+
67+
@router.delete(
68+
'/events/{event_id}/articles/{article_id}',
69+
name='EA-5',
70+
)
71+
async def delete_article_by_id(event_id: UUID, article_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> JSONResponse:
72+
"""Delete article"""
73+
return delete_article(db=db, event_id=event_id, article_id=article_id, current_user=current_user)
74+
75+
76+
@router.get(
77+
'/events/{event_id}/articles/all',
78+
name='EA-6',
79+
response_model=List[ArticleResponse],
80+
)
81+
async def get_all_articles(event_id: UUID, db: Session = Depends(get_db)) -> JSONResponse:
82+
"""Get all articles for an event"""
83+
return get_all_articles(db=db, event_id=event_id)

backend/app/v3/articles/schemas.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
from uuid import UUID
4+
5+
from pydantic import BaseModel
6+
7+
from app.v3.auth.schemas import UserResponse
8+
9+
10+
class ArticleBase(BaseModel):
11+
"""Base article model"""
12+
13+
title: str
14+
slug: Optional[str]
15+
content: str
16+
published_at: Optional[datetime]
17+
18+
19+
class ArticleCreate(ArticleBase):
20+
"""Create article input model"""
21+
22+
pass
23+
24+
25+
class ArticleUpdate(ArticleBase):
26+
"""Update article input model"""
27+
28+
pass
29+
30+
31+
class ArticleResponse(ArticleBase):
32+
"""Article response model"""
33+
34+
id: UUID
35+
event_id: UUID
36+
created_by: UserResponse
37+
created_at: datetime
38+
updated_at: datetime
39+
deleted_at: Optional[datetime]
40+
41+
class Config:
42+
"""Pydantic config"""
43+
44+
orm_mode = True
45+
46+
47+
class ArticleListResponse(BaseModel):
48+
"""Article list response model"""
49+
50+
articles: list[ArticleResponse]
51+
total: int
52+
skip: int
53+
limit: int

backend/app/v3/articles/service.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import logging
2+
3+
from pydantic import TypeAdapter
4+
from pydantic.v1 import UUID4
5+
from sqlalchemy.exc import IntegrityError
6+
from sqlalchemy.orm import Session
7+
from tunsberg.responses import (
8+
response_bad_request,
9+
response_conflict,
10+
response_created,
11+
response_no_content,
12+
response_not_found,
13+
response_success,
14+
)
15+
16+
from app.models.article import Article
17+
from app.models.event import Event
18+
from app.models.user import User
19+
from app.v3.articles.schemas import ArticleCreate, ArticleResponse, ArticleUpdate
20+
21+
22+
def create_article(db: Session, event_id: UUID4, current_user: User, article_data: ArticleCreate):
23+
"""Create a new article"""
24+
# Check if event exists
25+
event = db.query(Event).filter(Event.id == event_id).first()
26+
if not event:
27+
return response_bad_request(message='Event not found')
28+
29+
try:
30+
article = Article(**article_data.model_dump(), created_by_id=current_user.id, event_id=event_id)
31+
db.add(article)
32+
db.commit()
33+
db.refresh(article)
34+
35+
adapter = TypeAdapter(ArticleResponse)
36+
return response_created(message='Article created', data=adapter.dump_json(article))
37+
except IntegrityError as e:
38+
db.rollback()
39+
logging.error(f'Error creating article: {e}')
40+
return response_conflict(message='Error creating article')
41+
42+
43+
def get_articles(db: Session, event_id: UUID4, skip: int = 0, limit: int = 100):
44+
"""Get published articles for an event"""
45+
# Check if event exists
46+
event = db.query(Event).filter(Event.id == event_id).first()
47+
if not event:
48+
return response_bad_request(message='Event not found')
49+
50+
articles = (
51+
db.query(Article)
52+
.filter(Article.event_id == event_id, Article.deleted_at.is_(None), Article.published_at.isnot(None))
53+
.order_by(Article.published_at.desc())
54+
.offset(skip)
55+
.limit(limit)
56+
.all()
57+
)
58+
59+
adapter = TypeAdapter(list[ArticleResponse])
60+
data = adapter.dump_json(articles)
61+
return response_success(message='Articles retrieved', data=data)
62+
63+
64+
def get_article(db: Session, event_id: UUID4, article_id: UUID4):
65+
"""Get article by ID"""
66+
# Check if event exists
67+
event = db.query(Event).filter(Event.id == event_id).first()
68+
if not event:
69+
return response_bad_request(message='Event not found')
70+
71+
article = db.query(Article).filter(Article.id == article_id, Article.event_id == event_id, Article.deleted_at.is_(None)).first()
72+
if not article:
73+
return response_not_found(message='Article not found')
74+
75+
adapter = TypeAdapter(ArticleResponse)
76+
return response_success(message='Article retrieved', data=adapter.dump_json(article))
77+
78+
79+
def update_article(db: Session, event_id: UUID4, article_id: UUID4, current_user: User, article_data: ArticleUpdate):
80+
"""Update article"""
81+
# Check if event exists
82+
event = db.query(Event).filter(Event.id == event_id).first()
83+
if not event:
84+
return response_bad_request(message='Event not found')
85+
86+
article = db.query(Article).filter(Article.id == article_id, Article.deleted_at.is_(None)).first()
87+
if not article:
88+
return response_not_found(message='Article not found')
89+
90+
try:
91+
for field, value in article_data.model_dump(exclude_unset=True).items():
92+
setattr(article, field, value)
93+
94+
db.commit()
95+
db.refresh(article)
96+
97+
adapter = TypeAdapter(ArticleResponse)
98+
return response_success(message='Article updated', data=adapter.dump_json(article))
99+
except IntegrityError as e:
100+
db.rollback()
101+
logging.error(f'Error updating article: {e}')
102+
return response_conflict(message='Error updating article')
103+
104+
105+
def delete_article(db: Session, event_id: UUID4, article_id: UUID4, current_user: User):
106+
"""Delete article"""
107+
article = db.query(Article).filter(Article.id == article_id, Article.event_id == event_id, Article.deleted_at.is_(None)).first()
108+
if not article:
109+
return response_not_found(message='Article not found')
110+
111+
try:
112+
db.delete(article)
113+
db.commit()
114+
return response_no_content()
115+
except Exception as e:
116+
db.rollback()
117+
logging.error(f'Error deleting article: {e}')
118+
return response_bad_request(message='Could not delete article')
119+
120+
121+
def get_all_articles(db: Session, event_id: UUID4):
122+
"""Get all articles for an event"""
123+
articles = db.query(Article).filter(Article.event_id == event_id, Article.deleted_at.is_(None)).all()
124+
adapter = TypeAdapter(list[ArticleResponse])
125+
data = adapter.dump_json(articles)
126+
return response_success(message='Articles retrieved', data=data)

0 commit comments

Comments
 (0)