Skip to content

Commit 6747aed

Browse files
feat: add project support
1 parent fc17d0b commit 6747aed

File tree

15 files changed

+873
-2
lines changed

15 files changed

+873
-2
lines changed

app/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
from app.extensions import migrate
1212

1313
from app.controllers.member_controller import create_member_bp
14+
from app.controllers.project_controller import create_project_bp
1415

1516
from app.repositories.member_repository import MemberRepository
17+
from app.repositories.project_repository import ProjectRepository
1618

1719

18-
def create_app(config_class=Config, *, member_repo=None):
20+
def create_app(config_class=Config, *, member_repo=None, project_repo=None, access_control=False):
1921
flask_app = Flask(__name__)
2022
flask_app.config.from_object(config_class)
2123

@@ -28,6 +30,11 @@ def create_app(config_class=Config, *, member_repo=None):
2830
member_bp = create_member_bp(member_repo=member_repo)
2931
flask_app.register_blueprint(member_bp)
3032

33+
if project_repo is None:
34+
project_repo = ProjectRepository(db=db)
35+
project_bp = create_project_bp(project_repo=project_repo)
36+
flask_app.register_blueprint(project_bp)
37+
3138
from werkzeug.exceptions import HTTPException
3239
flask_app.register_error_handler(HTTPException, handle_http_exception)
3340
from pydantic import ValidationError

app/controllers/auth_controller.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import bcrypt
2+
import secrets
3+
import requests
4+
import random
5+
import base62
6+
7+
from pydantic import ValidationError
8+
9+
from urllib.parse import urlencode
10+
11+
from flask import Blueprint, request, abort, session, current_app, redirect
12+
from http import HTTPStatus
13+
14+
from app.schemas.login_schema import LoginSchema, FenixOAuthSchema, FenixStudentSchema
15+
from app.schemas.member_schema import MemberSchema
16+
17+
from app.repositories.member_repository import MemberRepository
18+
19+
20+
def create_auth_bp(member_repo: MemberRepository):
21+
bp = Blueprint("auth", __name__)
22+
23+
@bp.route("/login", methods=["POST"])
24+
def login():
25+
login_data = LoginSchema(**request.json())
26+
27+
if (member := member_repo.get_member_by_username(login_data.username)) is None:
28+
abort(HTTPStatus.UNAUTHORIZED)
29+
30+
if member.password is None: # fenix authenticated users must login through Fenix
31+
abort(HTTPStatus.UNAUTHORIZED)
32+
33+
if not bcrypt.checkpw(login_data.password.encode("utf-8"), member.password.encode("utf-8")):
34+
abort(HTTPStatus.UNAUTHORIZED)
35+
36+
session.clear()
37+
38+
session["username"] = member.username
39+
return {"message": f"Welcome {member.username}!"}
40+
41+
@bp.route("/logout", methods=["GET"])
42+
def logout():
43+
session.clear()
44+
return {"message": "Logged out sucessfully"}
45+
46+
@bp.route("/fenix-auth")
47+
def fenix_auth():
48+
if (
49+
current_app.config.get("CLIENT_ID", "") == ""
50+
or current_app.config.get("CLIENT_SECRET", "") == ""
51+
or current_app.config.get("FENIX_REDIRECT_URL") == ""
52+
):
53+
abort(HTTPStatus.NOT_IMPLEMENTED)
54+
55+
state = secrets.token_hex(16)
56+
57+
session.clear()
58+
session["state"] = state
59+
params = {
60+
"client_id": current_app.config.get("CLIENT_ID"),
61+
"redirect_uri": current_app.config.get("FENIX_REDIRECT_URL"),
62+
"state": state,
63+
}
64+
return redirect(
65+
"https://fenix.tecnico.ulisboa.pt/oauth/userdialog?" + urlencode(params)
66+
)
67+
68+
@bp.route("/fenix-auth-callback")
69+
def fenix_auth_callback():
70+
try:
71+
oauth_data = FenixOAuthSchema(**request.args)
72+
except ValidationError:
73+
# this fails on invalid state parameters
74+
abort(HTTPStatus.UNAUTHORIZED)
75+
76+
if oauth_data.state is None or oauth_data.state != session.get("state", ""):
77+
abort(HTTPStatus.UNAUTHORIZED)
78+
79+
if oauth_data.error is not None:
80+
abort(HTTPStatus.BAD_GATEWAY, {"error": oauth_data.error})
81+
82+
params = {
83+
"client_id": current_app.config.get("CLIENT_ID"),
84+
"client_secret": current_app.config.get("CLIENT_SECRET"),
85+
"redirect_uri": current_app.config.get("FENIX_REDIRECT_URL"),
86+
"code": oauth_data.code,
87+
"grant_type": "authorization_code",
88+
}
89+
90+
if (
91+
access_token := __fetch_access_token(
92+
"https://fenix.tecnico.ulisboa.pt/oauth/access_token?"
93+
+ urlencode(params)
94+
)
95+
) is None:
96+
abort(HTTPStatus.BAD_GATEWAY)
97+
98+
if (user_data := __get_user_info(access_token)) is None:
99+
abort(HTTPStatus.BAD_GATEWAY)
100+
101+
session.clear()
102+
if (member := member_repo.get_member_by_ist_id(user_data.username)) is None:
103+
# attempt to create a user with a random username
104+
while True:
105+
while True:
106+
# generate a random username
107+
username = base62.encodebytes(random.randbytes(8)).lower()
108+
if member_repo.get_member_by_username(username) is None:
109+
break
110+
111+
try:
112+
pass
113+
# member = member_service.create_user(Member(MemberSchema(**user_data)))
114+
except Exception as e:
115+
print(e)
116+
# TODO replace with conflict exception
117+
continue
118+
break
119+
120+
session["username"] = member.username
121+
return {"message": "Registered"}
122+
123+
return bp
124+
125+
126+
def __fetch_access_token(url: str) -> str | None:
127+
r = requests.post(url)
128+
try:
129+
r.raise_for_status()
130+
except requests.HTTPError as e:
131+
current_app.logger.info(f"Failed requesting access token: {e}")
132+
return None
133+
134+
if r.status_code != 200:
135+
current_app.logger.info(
136+
f"Failed requesting access token with non 200 response. Status code: {r.status_code}\nMessage: {r.connection}"
137+
)
138+
return None
139+
140+
access_token: str
141+
try:
142+
access_token = r.json()["access_token"]
143+
except Exception as e:
144+
current_app.logger.info(f"Failed requesting access token: {e}")
145+
return None
146+
147+
return access_token
148+
149+
150+
def __get_user_info(
151+
access_token: str,
152+
) -> FenixStudentSchema | None:
153+
r = requests.get(
154+
"https://fenix.tecnico.ulisboa.pt/api/fenix/v1/person?"
155+
+ urlencode({"access_token": access_token})
156+
)
157+
try:
158+
r.raise_for_status()
159+
except requests.HTTPError as e:
160+
current_app.logger.info(f"Failed requesting user info: {e}")
161+
return None
162+
163+
if r.status_code != 200:
164+
current_app.logger.info(
165+
f"Failed requesting user info with non 200 status code. Status Code: {r.status_code}\nMessage: {r.content}"
166+
)
167+
return None
168+
169+
try:
170+
user_data = FenixStudentSchema(**r.json())
171+
except Exception as e:
172+
current_app.logger.info(f"Failed requesting user info: {e}")
173+
return None
174+
175+
return user_data
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from http import HTTPStatus
2+
3+
from flask import Blueprint
4+
from flask import request
5+
from flask import abort
6+
7+
from app.utils import slugify
8+
9+
from app.schemas.project_schema import ProjectSchema
10+
from app.schemas.update_project_schema import UpdateProjectSchema
11+
12+
from app.repositories.project_repository import ProjectRepository
13+
14+
from app.models.project_model import Project
15+
16+
17+
def create_project_bp(*, project_repo: ProjectRepository):
18+
bp = Blueprint("projects", __name__)
19+
20+
@bp.route("/projects", methods=["POST"])
21+
def create_project():
22+
project_data = ProjectSchema(**request.json)
23+
if project_repo.get_project_by_name(project_data.name) is not None:
24+
return abort(HTTPStatus.CONFLICT, description=f'Project with name "{project_data.name}" already exists')
25+
26+
slug = slugify(project_data.name)
27+
if project_repo.get_project_by_slug(slug):
28+
return abort(HTTPStatus.CONFLICT,
29+
description=f'A slug already exists for this name, please pick a new one: "{project_data.name}')
30+
31+
project = project_repo.create_project(Project.from_schema(project_data))
32+
return ProjectSchema.from_project(project).model_dump()
33+
34+
@bp.route("/projects", methods=["GET"])
35+
def get_projects():
36+
return [ProjectSchema.from_project(p).model_dump() for p in project_repo.get_projects()]
37+
38+
@bp.route("/projects/<slug>", methods=["GET"])
39+
def get_project_by_slug(slug):
40+
if (project := project_repo.get_project_by_slug(slug)) is None:
41+
return abort(HTTPStatus.NOT_FOUND, description=f'Project "{slug}" not found"')
42+
return ProjectSchema.from_project(project).model_dump()
43+
44+
@bp.route("/projects/<slug>", methods=["PUT"])
45+
def update_project_by_slug(slug):
46+
if (project := project_repo.get_project_by_slug(slug)) is None:
47+
return abort(HTTPStatus.NOT_FOUND, description=f'Project "{slug}" not found')
48+
49+
project_update = UpdateProjectSchema(**request.json)
50+
if project_update.name and project_repo.get_project_by_slug(slugify(project_update.name)) is not None:
51+
return abort(HTTPStatus.CONFLICT,
52+
description=f'A slug already exists for this name, please pick a new one: "{project_update.name}')
53+
54+
updated_project = project_repo.update_project(project, project_update)
55+
return ProjectSchema.from_project(updated_project).model_dump()
56+
57+
@bp.route("/projects/<slug>", methods=["DELETE"])
58+
def delete_project_by_slug(slug):
59+
if (project := project_repo.get_project_by_slug(slug)) is None:
60+
return abort(HTTPStatus.NOT_FOUND, description=f'Project "{slug}" not found')
61+
62+
name = project_repo.delete_project(project)
63+
return {f"description": "Project deleted successfully", "name": name}
64+
65+
return bp

app/models/project_model.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from typing import TYPE_CHECKING
2+
from sqlalchemy.orm import Mapped, mapped_column, validates
3+
from sqlalchemy import select, Enum
4+
5+
from app.extensions import db
6+
7+
from app.utils import is_valid_datestring, slugify, ProjectStateEnum
8+
9+
if TYPE_CHECKING:
10+
from app.schemas.project_schema import ProjectSchema
11+
12+
class Project(db.Model):
13+
__tablename__ = "projects"
14+
15+
_name: Mapped[str] = mapped_column("name", primary_key=True)
16+
slug: Mapped[str] = mapped_column(unique=True)
17+
state: Mapped[ProjectStateEnum] = mapped_column(Enum(ProjectStateEnum, native_enum=False))
18+
start_date: Mapped[str] = mapped_column()
19+
20+
end_date: Mapped[str] = mapped_column(nullable=True)
21+
description: Mapped[str] = mapped_column(nullable=True)
22+
23+
@classmethod
24+
def from_schema(cls, schema: "ProjectSchema"):
25+
data = schema.model_dump()
26+
if "slug" in data:
27+
del data["slug"]
28+
return cls(**data)
29+
30+
def __init__(self, *, name=None, state=None, start_date=None, end_date=None, description=None):
31+
self.name = name
32+
self.state = state
33+
self.start_date = start_date
34+
self.end_date = end_date
35+
self.description = description
36+
37+
@property
38+
def name(self):
39+
return self._name
40+
41+
@name.setter
42+
def name(self, value):
43+
self._name = self.validate_name("name", value)
44+
self.slug = slugify(value)
45+
46+
@validates("name")
47+
def validate_name(self, k, v):
48+
if not isinstance(v, str):
49+
raise ValueError(f'Invalid name type: "{type(v)}"')
50+
if isinstance(v, str) and not 2 <= len(v) <= 64:
51+
raise ValueError(f'Invalid name length, minimum 2 and maximum 64 characters: "{v}"')
52+
return v
53+
54+
@validates("slug")
55+
def validate_slug(self, k, v):
56+
if db.session.execute(select(Project).where(Project.slug == v)).one_or_none() is not None:
57+
raise ValueError(f'A slug already exists for this name, please pick a new one: "{v}"')
58+
return v
59+
60+
@validates("state")
61+
def validate_state(self, k, v):
62+
if not isinstance(v, ProjectStateEnum):
63+
raise ValueError(f'Invalid state type: "{type(v)}"')
64+
return v
65+
66+
@validates("start_date")
67+
def validate_start_date(self, k, v):
68+
if not isinstance(v, str):
69+
raise ValueError(f'Invalid start_date type: "{type(v)}"')
70+
if not is_valid_datestring(v):
71+
raise ValueError(f'Invalid start_date format, expected "YYYY-MM-DD": "{v}"')
72+
return v
73+
74+
@validates("end_date")
75+
def validate_end_date(self, k, v):
76+
if v is None:
77+
return None
78+
if not isinstance(v, str):
79+
raise ValueError(f'Invalid end_date type: "{type(v)}"')
80+
if not is_valid_datestring(v):
81+
raise ValueError(f'Invalid end_date format, expected "YYYY-MM-DD": "{v}"')
82+
return v
83+
84+
@validates("description")
85+
def validate_description(self, k, v):
86+
if v is None:
87+
return None
88+
if not isinstance(v, str):
89+
raise ValueError(f'Invalid {k} type: "{type(v)}"')
90+
if len(v) > 2048:
91+
raise ValueError(f'Invalid {k} length, minimum 0 and maximum 2048 characters: "{v}"')
92+
return v
93+
94+
def __repr__(self):
95+
return f"<{self.__class__.__name__}({', '.join(f'{k}={v!r}' for k, v in self.__dict__.items())})>"
96+
97+

0 commit comments

Comments
 (0)