diff --git a/backend/src/app/logic/editions.py b/backend/src/app/logic/editions.py index 26195b277..05fb2bb69 100644 --- a/backend/src/app/logic/editions.py +++ b/backend/src/app/logic/editions.py @@ -24,24 +24,16 @@ async def get_edition_by_name(db: AsyncSession, edition_name: str) -> EditionMod async def create_edition(db: AsyncSession, edition: EditionBase) -> EditionModel: - """ Create a new edition. - - Args: - db (Session): connection with the database. - - Returns: - Edition: the newly made edition object. - """ + """Create a new edition.""" return await crud_editions.create_edition(db, edition) async def delete_edition(db: AsyncSession, edition_name: str): - """Delete an existing edition. + """Delete an existing edition.""" + await crud_editions.delete_edition(db, edition_name) - Args: - db (Session): connection with the database. - edition_name (str): the name of the edition that needs to be deleted, if found. - Returns: nothing - """ - await crud_editions.delete_edition(db, edition_name) +async def patch_edition(db: AsyncSession, edition: EditionModel, readonly: bool) -> EditionModel: + """Edit an existing edition""" + await crud_editions.patch_edition(db, edition, readonly) + return edition diff --git a/backend/src/app/logic/users.py b/backend/src/app/logic/users.py index 805f8c705..b0e9bd488 100644 --- a/backend/src/app/logic/users.py +++ b/backend/src/app/logic/users.py @@ -3,7 +3,7 @@ import src.database.crud.users as users_crud from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse, user_model_to_schema, \ FilterParameters, UserRequest -from src.database.models import User +from src.database.models import User, Edition async def get_users_list( @@ -20,9 +20,9 @@ async def get_users_list( return UsersListResponse(users=[user_model_to_schema(user) for user in users_orm]) -async def get_user_editions(db: AsyncSession, user: User) -> list[str]: +async def get_user_editions(db: AsyncSession, user: User) -> list[Edition]: """Get all names of the editions this user is coach in""" - return await users_crud.get_user_edition_names(db, user) + return await users_crud.get_user_editions(db, user) async def edit_admin_status(db: AsyncSession, user_id: int, admin: AdminPatch): diff --git a/backend/src/app/routers/editions/editions.py b/backend/src/app/routers/editions/editions.py index 71c6b6a06..359fb8cc6 100644 --- a/backend/src/app/routers/editions/editions.py +++ b/backend/src/app/routers/editions/editions.py @@ -8,19 +8,19 @@ from src.app.logic import editions as logic_editions from src.app.routers.tags import Tags -from src.app.schemas.editions import EditionBase, Edition, EditionList +from src.app.schemas.editions import EditionBase, Edition, EditionList, EditEdition from src.database.database import get_session -from src.database.models import User +from src.database.models import User, Edition as EditionDB from .invites import invites_router from .projects import projects_router from .register import registration_router from .students import students_router from .webhooks import webhooks_router -from ...utils.dependencies import require_admin, require_auth, require_coach, require_coach_ws -# Don't add the "Editions" tag here, because then it gets applied -# to all child routes as well +from ...utils.dependencies import require_admin, require_auth, require_coach, require_coach_ws, get_edition from ...utils.websockets import DataPublisher, get_publisher +# Don't add the "Editions" tag here, because then it gets applied +# to all child routes as well editions_router = APIRouter(prefix="/editions") # Register all child routers @@ -45,6 +45,17 @@ async def get_editions(db: AsyncSession = Depends(get_session), user: User = Dep return EditionList(editions=user.editions) +@editions_router.patch("/{edition_name}", response_class=Response, tags=[Tags.EDITIONS], + dependencies=[Depends(require_admin)], status_code=status.HTTP_204_NO_CONTENT) +async def patch_edition(edit_edition: EditEdition, edition: EditionDB = Depends(get_edition), + db: AsyncSession = Depends(get_session)): + """Change the readonly status of an edition + Note that this route is not behind "get_editable_edition", because otherwise you'd never be able + to change the status back to False + """ + await logic_editions.patch_edition(db, edition, edit_edition.readonly) + + @editions_router.get( "/{edition_name}", response_model=Edition, diff --git a/backend/src/app/routers/editions/invites/invites.py b/backend/src/app/routers/editions/invites/invites.py index 9cdfab718..ca61acee1 100644 --- a/backend/src/app/routers/editions/invites/invites.py +++ b/backend/src/app/routers/editions/invites/invites.py @@ -5,7 +5,7 @@ from src.app.logic.invites import create_mailto_link, delete_invite_link, get_pending_invites_page from src.app.routers.tags import Tags -from src.app.utils.dependencies import get_edition, get_invite_link, require_admin, get_latest_edition +from src.app.utils.dependencies import get_edition, get_invite_link, require_admin, get_editable_edition from src.app.schemas.invites import InvitesLinkList, EmailAddress, NewInviteLink, InviteLink as InviteLinkModel from src.database.database import get_session from src.database.models import Edition, InviteLink as InviteLinkDB @@ -24,7 +24,7 @@ async def get_invites(db: AsyncSession = Depends(get_session), edition: Edition @invites_router.post("", status_code=status.HTTP_201_CREATED, response_model=NewInviteLink, dependencies=[Depends(require_admin)]) async def create_invite(email: EmailAddress, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """ Create a new invitation link for the current edition. """ diff --git a/backend/src/app/routers/editions/projects/projects.py b/backend/src/app/routers/editions/projects/projects.py index b0e48e6fb..e68f7aa62 100644 --- a/backend/src/app/routers/editions/projects/projects.py +++ b/backend/src/app/routers/editions/projects/projects.py @@ -11,7 +11,7 @@ from src.app.schemas.projects import ( ProjectList, Project, InputProject, ConflictStudentList, QueryParamsProjects ) -from src.app.utils.dependencies import get_edition, get_project, require_admin, require_coach, get_latest_edition +from src.app.utils.dependencies import get_edition, get_project, require_admin, require_coach, get_editable_edition from src.app.utils.websockets import live from src.database.database import get_session from src.database.models import Edition, Project as ProjectModel, User @@ -40,7 +40,7 @@ async def get_projects( async def create_project( input_project: InputProject, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """Create a new project""" return await logic.create_project(db, edition, input_project) @@ -79,7 +79,7 @@ async def get_project_route(project: ProjectModel = Depends(get_project)): @projects_router.patch( "/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, - dependencies=[Depends(require_admin), Depends(get_latest_edition), Depends(live)] + dependencies=[Depends(require_admin), Depends(get_editable_edition), Depends(live)] ) async def patch_project( input_project: InputProject, @@ -104,7 +104,7 @@ async def get_project_roles(project: ProjectModel = Depends(get_project), db: As @projects_router.post( "/{project_id}/roles", response_model=ProjectRoleSchema, - dependencies=[Depends(require_admin), Depends(get_latest_edition), Depends(live)] + dependencies=[Depends(require_admin), Depends(get_editable_edition), Depends(live)] ) async def post_project_role( input_project_role: InputProjectRole, @@ -117,7 +117,7 @@ async def post_project_role( @projects_router.patch( "/{project_id}/roles/{project_role_id}", response_model=ProjectRoleSchema, - dependencies=[Depends(require_admin), Depends(get_latest_edition), Depends(get_project), Depends(live)] + dependencies=[Depends(require_admin), Depends(get_editable_edition), Depends(get_project), Depends(live)] ) async def patch_project_role( input_project_role: InputProjectRole, diff --git a/backend/src/app/routers/editions/projects/students/projects_students.py b/backend/src/app/routers/editions/projects/students/projects_students.py index f69dc34c7..667439913 100644 --- a/backend/src/app/routers/editions/projects/students/projects_students.py +++ b/backend/src/app/routers/editions/projects/students/projects_students.py @@ -7,7 +7,7 @@ from src.app.routers.tags import Tags from src.app.schemas.projects import InputArgumentation, ReturnProjectRoleSuggestion from src.app.utils.dependencies import ( - require_coach, get_latest_edition, get_student, + require_coach, get_editable_edition, get_student, get_project_role, get_edition ) from src.app.utils.websockets import live @@ -35,7 +35,7 @@ async def remove_student_from_project( @project_students_router.patch( "/{student_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, - dependencies=[Depends(get_latest_edition), Depends(live)] + dependencies=[Depends(get_editable_edition), Depends(live)] ) async def change_project_role( argumentation: InputArgumentation, @@ -52,7 +52,7 @@ async def change_project_role( @project_students_router.post( "/{student_id}", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(get_latest_edition), Depends(live)], + dependencies=[Depends(get_editable_edition), Depends(live)], response_model=ReturnProjectRoleSuggestion ) async def add_student_to_project( diff --git a/backend/src/app/routers/editions/register/register.py b/backend/src/app/routers/editions/register/register.py index dbb978005..24eab4f0e 100644 --- a/backend/src/app/routers/editions/register/register.py +++ b/backend/src/app/routers/editions/register/register.py @@ -7,7 +7,7 @@ from src.app.logic.register import create_request_email, create_request_github from src.app.routers.tags import Tags from src.app.schemas.register import EmailRegister, GitHubRegister -from src.app.utils.dependencies import get_latest_edition, get_http_session +from src.app.utils.dependencies import get_editable_edition, get_http_session from src.database.database import get_session from src.database.models import Edition @@ -16,7 +16,7 @@ @registration_router.post("/email", status_code=status.HTTP_201_CREATED) async def register_email(register_data: EmailRegister, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """ Register a new account using the email/password format. """ @@ -26,7 +26,7 @@ async def register_email(register_data: EmailRegister, db: AsyncSession = Depend @registration_router.post("/github", status_code=status.HTTP_201_CREATED) async def register_github(register_data: GitHubRegister, db: AsyncSession = Depends(get_session), http_session: ClientSession = Depends(get_http_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """Register a new account using GitHub OAuth.""" access_token_data = await get_github_access_token(http_session, register_data.code) user_email = await get_github_profile(http_session, access_token_data.access_token) diff --git a/backend/src/app/routers/editions/students/students.py b/backend/src/app/routers/editions/students/students.py index d97b7e61a..9902aa1ef 100644 --- a/backend/src/app/routers/editions/students/students.py +++ b/backend/src/app/routers/editions/students/students.py @@ -14,7 +14,7 @@ ReturnStudentMailList, NewEmail, EmailsSearchQueryParams, ListReturnStudentMailList ) -from src.app.utils.dependencies import get_latest_edition, get_student, get_edition, require_admin, require_auth +from src.app.utils.dependencies import get_editable_edition, get_student, get_edition, require_admin, require_auth from src.app.utils.websockets import live from src.database.database import get_session from src.database.models import Student, Edition, User @@ -47,7 +47,7 @@ async def get_students(db: AsyncSession = Depends(get_session), async def send_emails( new_email: NewEmail, db: AsyncSession = Depends(get_session), - edition: Edition = Depends(get_latest_edition)): + edition: Edition = Depends(get_editable_edition)): """ Send a email to a list of students. """ @@ -92,7 +92,7 @@ async def get_student_by_id(edition: Edition = Depends(get_edition), student: St @students_router.put( "/{student_id}/decision", - dependencies=[Depends(require_admin), Depends(live), Depends(get_latest_edition)], + dependencies=[Depends(require_admin), Depends(live), Depends(get_editable_edition)], status_code=status.HTTP_204_NO_CONTENT ) async def make_decision( diff --git a/backend/src/app/routers/editions/students/suggestions/suggestions.py b/backend/src/app/routers/editions/students/suggestions/suggestions.py index 8222697af..7479832b6 100644 --- a/backend/src/app/routers/editions/students/suggestions/suggestions.py +++ b/backend/src/app/routers/editions/students/suggestions/suggestions.py @@ -5,7 +5,7 @@ from starlette.responses import Response from src.app.routers.tags import Tags -from src.app.utils.dependencies import get_latest_edition, require_auth, get_student, get_suggestion +from src.app.utils.dependencies import get_editable_edition, require_auth, get_student, get_suggestion from src.app.utils.websockets import live from src.database.database import get_session from src.database.models import Student, User, Suggestion @@ -22,7 +22,7 @@ "", status_code=status.HTTP_201_CREATED, response_model=SuggestionResponse, - dependencies=[Depends(live), Depends(get_latest_edition)] + dependencies=[Depends(live), Depends(get_editable_edition)] ) async def create_suggestion(new_suggestion: NewSuggestion, student: Student = Depends(get_student), db: AsyncSession = Depends(get_session), user: User = Depends(require_auth)): @@ -51,7 +51,7 @@ async def delete_suggestion(db: AsyncSession = Depends(get_session), user: User @students_suggestions_router.put( "/{suggestion_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(get_student), Depends(live), Depends(get_latest_edition)] + dependencies=[Depends(get_student), Depends(live), Depends(get_editable_edition)] ) async def edit_suggestion(new_suggestion: NewSuggestion, db: AsyncSession = Depends(get_session), user: User = Depends(require_auth), suggestion: Suggestion = Depends(get_suggestion)): diff --git a/backend/src/app/routers/editions/webhooks/webhooks.py b/backend/src/app/routers/editions/webhooks/webhooks.py index 8924eb612..139818aef 100644 --- a/backend/src/app/routers/editions/webhooks/webhooks.py +++ b/backend/src/app/routers/editions/webhooks/webhooks.py @@ -5,7 +5,7 @@ from src.app.logic.webhooks import process_webhook from src.app.routers.tags import Tags from src.app.schemas.webhooks import WebhookEvent, WebhookUrlResponse -from src.app.utils.dependencies import get_edition, require_admin, get_latest_edition +from src.app.utils.dependencies import get_edition, require_admin, get_editable_edition from src.database.crud.webhooks import get_webhook, create_webhook from src.database.database import get_session from src.database.models import Edition @@ -20,7 +20,7 @@ async def valid_uuid(uuid: str, database: AsyncSession = Depends(get_session)): @webhooks_router.post("", response_model=WebhookUrlResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)]) -async def new(edition: Edition = Depends(get_latest_edition), database: AsyncSession = Depends(get_session)): +async def new(edition: Edition = Depends(get_editable_edition), database: AsyncSession = Depends(get_session)): """Create a new webhook for an edition""" return await create_webhook(database, edition) diff --git a/backend/src/app/routers/login/login.py b/backend/src/app/routers/login/login.py index dee681710..d4b33ee78 100644 --- a/backend/src/app/routers/login/login.py +++ b/backend/src/app/routers/login/login.py @@ -9,6 +9,7 @@ from src.app.logic.security import authenticate_user_email, create_tokens, authenticate_user_github from src.app.logic.users import get_user_editions from src.app.routers.tags import Tags +from src.app.schemas.editions import Edition from src.app.schemas.login import Token from src.app.schemas.users import user_model_to_schema from src.app.utils.dependencies import get_user_from_refresh_token, get_http_session @@ -61,7 +62,8 @@ async def generate_token_response_for_user(db: AsyncSession, user: User) -> Toke access_token, refresh_token = create_tokens(user) user_data: dict = user_model_to_schema(user).__dict__ - user_data["editions"] = await get_user_editions(db, user) + editions = await get_user_editions(db, user) + user_data["editions"] = list(map(Edition.from_orm, editions)) return Token( access_token=access_token, diff --git a/backend/src/app/routers/users/users.py b/backend/src/app/routers/users/users.py index 681bc2396..5f815750a 100644 --- a/backend/src/app/routers/users/users.py +++ b/backend/src/app/routers/users/users.py @@ -5,6 +5,7 @@ import src.app.logic.users as logic from src.app.routers.tags import Tags +from src.app.schemas.editions import Edition from src.app.schemas.login import UserData from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse, user_model_to_schema, \ FilterParameters @@ -32,8 +33,8 @@ async def get_users( async def get_current_user(db: AsyncSession = Depends(get_session), user: UserDB = Depends(get_user_from_access_token)): """Get a user based on their authorization credentials""" user_data = user_model_to_schema(user).__dict__ - user_data["editions"] = await logic.get_user_editions(db, user) - + editions = await logic.get_user_editions(db, user) + user_data["editions"] = list(map(Edition.from_orm, editions)) return user_data diff --git a/backend/src/app/schemas/editions.py b/backend/src/app/schemas/editions.py index 3b3618d0e..8f89ce5ea 100644 --- a/backend/src/app/schemas/editions.py +++ b/backend/src/app/schemas/editions.py @@ -22,6 +22,7 @@ class Edition(CamelCaseModel): edition_id: int name: str year: int + readonly: bool class Config: """Set to ORM mode""" @@ -35,3 +36,10 @@ class EditionList(CamelCaseModel): class Config: """Set to ORM mode""" orm_mode = True + + +class EditEdition(CamelCaseModel): + """Input schema to edit an edition + Only supported operation is patching the readonly status + """ + readonly: bool diff --git a/backend/src/app/schemas/login.py b/backend/src/app/schemas/login.py index b7e735e73..a4ca1e439 100644 --- a/backend/src/app/schemas/login.py +++ b/backend/src/app/schemas/login.py @@ -1,10 +1,11 @@ +from src.app.schemas.editions import Edition from src.app.schemas.users import User from src.app.schemas.utils import BaseModel class UserData(User): """User information that can be passed to frontend""" - editions: list[str] = [] + editions: list[Edition] = [] class Token(BaseModel): diff --git a/backend/src/app/schemas/projects.py b/backend/src/app/schemas/projects.py index 8267067b6..d2526880e 100644 --- a/backend/src/app/schemas/projects.py +++ b/backend/src/app/schemas/projects.py @@ -1,9 +1,10 @@ from dataclasses import dataclass -from pydantic import BaseModel +from pydantic import BaseModel, validator from src.app.schemas.skills import Skill from src.app.schemas.utils import CamelCaseModel +from src.app.schemas.validators import validate_url class User(CamelCaseModel): @@ -82,6 +83,7 @@ class Project(CamelCaseModel): """Represents a Project from the database to return when a GET request happens""" project_id: int name: str + info_url: str | None coaches: list[User] partners: list[Partner] @@ -101,6 +103,7 @@ class ConflictProject(CamelCaseModel): """A project to be used in ConflictStudent""" project_id: int name: str + info_url: str | None class Config: """Config Class""" @@ -154,9 +157,16 @@ class InputProjectRole(BaseModel): class InputProject(BaseModel): """Used for passing the details of a project when creating/patching a project""" name: str + info_url: str | None partners: list[str] coaches: list[int] + @validator('info_url') + @classmethod + def is_url(cls, info_url: str | None): + """Validate url""" + return validate_url(info_url) + class InputArgumentation(BaseModel): """Used for creating/patching a student role""" diff --git a/backend/src/app/schemas/validators.py b/backend/src/app/schemas/validators.py index ef956708d..9064a5a0f 100644 --- a/backend/src/app/schemas/validators.py +++ b/backend/src/app/schemas/validators.py @@ -21,3 +21,12 @@ def validate_edition(edition: str): """ if not re.fullmatch(r"[a-zA-Z0-9_-]+", edition): raise ValidationException("Spaces detected in the edition name") + + +def validate_url(info_url: str | None): + """Verify the info_url is actually an url""" + if not info_url: + return None + if info_url.startswith('https://') or info_url.startswith('http://'): + return info_url + raise ValidationException('info_url should be a link starting with http:// or https://') diff --git a/backend/src/app/utils/dependencies.py b/backend/src/app/utils/dependencies.py index c42cfe49c..228608cf4 100644 --- a/backend/src/app/utils/dependencies.py +++ b/backend/src/app/utils/dependencies.py @@ -18,7 +18,7 @@ from src.app.exceptions.editions import ReadOnlyEditionException from src.app.exceptions.util import NotFound from src.app.logic.security import ALGORITHM, TokenType -from src.database.crud.editions import get_edition_by_name, latest_edition +from src.database.crud.editions import get_edition_by_name from src.database.crud.invites import get_invite_link_by_uuid from src.database.crud.students import get_student_by_id from src.database.crud.suggestions import get_suggestion_by_id @@ -50,13 +50,12 @@ async def get_suggestion(suggestion_id: int, database: AsyncSession = Depends(ge return suggestion -async def get_latest_edition(edition: Edition = Depends(get_edition), database: AsyncSession = Depends(get_session)) \ +async def get_editable_edition(edition: Edition = Depends(get_edition)) \ -> Edition: - """Checks if the given edition is the latest one (others are read-only) and returns it if it is""" - latest = await latest_edition(database) - if edition != latest: + """Checks if the requested edition is editable, and returns it if it is""" + if edition.readonly: raise ReadOnlyEditionException - return latest + return edition oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login/token/email") diff --git a/backend/src/database/crud/editions.py b/backend/src/database/crud/editions.py index f871b65ea..ac147041e 100644 --- a/backend/src/database/crud/editions.py +++ b/backend/src/database/crud/editions.py @@ -1,4 +1,4 @@ -from sqlalchemy import exc, func, select, desc +from sqlalchemy import exc, select, desc from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import Select @@ -24,7 +24,7 @@ async def get_edition_by_name(db: AsyncSession, edition_name: str) -> Edition: def _get_editions_query() -> Select: - return select(Edition).order_by(desc(Edition.edition_id)) + return select(Edition).order_by(desc(Edition.year), desc(Edition.edition_id)) async def get_editions(db: AsyncSession) -> list[Edition]: @@ -70,12 +70,7 @@ async def delete_edition(db: AsyncSession, edition_name: str): await db.commit() -async def latest_edition(db: AsyncSession) -> Edition: - """Returns the latest edition from the database""" - subquery = select(func.max(Edition.edition_id)) - result = await db.execute(subquery) - max_edition_id = result.scalar() - - query = select(Edition).where(Edition.edition_id == max_edition_id) - result2 = await db.execute(query) - return result2.scalars().one() +async def patch_edition(db: AsyncSession, edition: Edition, readonly: bool): + """Update the readonly status of an edition""" + edition.readonly = readonly + await db.commit() diff --git a/backend/src/database/crud/projects.py b/backend/src/database/crud/projects.py index 003703664..1cdb30645 100644 --- a/backend/src/database/crud/projects.py +++ b/backend/src/database/crud/projects.py @@ -48,6 +48,7 @@ async def create_project( project = Project( name=input_project.name, + info_url=input_project.info_url, edition_id=edition.edition_id, coaches=coaches, partners=partners @@ -89,6 +90,7 @@ async def patch_project( coaches = [await get_user_by_id(db, coach) for coach in input_project.coaches] project.name = input_project.name + project.info_url = input_project.info_url project.coaches = coaches project.partners = partners diff --git a/backend/src/database/crud/users.py b/backend/src/database/crud/users.py index c684308c7..21df9a718 100644 --- a/backend/src/database/crud/users.py +++ b/backend/src/database/crud/users.py @@ -9,32 +9,17 @@ from src.database.models import user_editions, User, Edition, CoachRequest, AuthEmail, AuthGitHub, AuthGoogle -async def get_user_edition_names(db: AsyncSession, user: User) -> list[str]: +async def get_user_editions(db: AsyncSession, user: User) -> list[Edition]: """Get all names of the editions this user can see""" # For admins: return all editions - otherwise, all editions this user is verified coach in - source = user.editions if not user.admin else await get_editions(db) - - editions = [] - # Name & year are non-nullable in the database, so it can never be None, - # but MyPy doesn't seem to grasp that concept just yet so we have to check it - # Could be a oneliner/list comp but that's a bit less readable - # Return from newest to oldest - for edition in sorted(source, key=lambda e: e.year or -1, reverse=True): - if edition.name is not None: - editions.append(edition.name) - - return editions + # Sort by year first, id second, descending + return sorted(user.editions, key=lambda x: (x.year, x.edition_id), + reverse=True) if not user.admin else await get_editions(db) async def get_users_filtered_page(db: AsyncSession, params: FilterParameters): """ Get users and filter by optional parameters: - :param admin: only return admins / only return non-admins - :param edition_name: only return users who are coach of the given edition - :param exclude_edition_name: only return users who are not coach of the given edition - :param name: a string which the user's name must contain - :param page: the page to return - Note: When the admin parameter is set, edition_name and exclude_edition_name will be ignored. """ @@ -57,7 +42,7 @@ async def get_users_filtered_page(db: AsyncSession, params: FilterParameters): if params.exclude_edition is not None: exclude_edition = await get_edition_by_name(db, params.exclude_edition) - exclude_user_id = select(user_editions.c.user_id)\ + exclude_user_id = select(user_editions.c.user_id) \ .where(user_editions.c.edition_id == exclude_edition.edition_id) query = query.filter(User.user_id.not_in(exclude_user_id)) @@ -185,7 +170,7 @@ async def reject_request(db: AsyncSession, request_id: int): async def remove_request_if_exists(db: AsyncSession, user_id: int, edition_name: str): """Remove a pending request for a user if there is one, otherwise do nothing""" edition = (await db.execute(select(Edition).where(Edition.name == edition_name))).scalar_one() - delete_query = delete(CoachRequest).where(CoachRequest.user_id == user_id)\ + delete_query = delete(CoachRequest).where(CoachRequest.user_id == user_id) \ .where(CoachRequest.edition_id == edition.edition_id) await db.execute(delete_query) await db.commit() diff --git a/backend/src/database/models.py b/backend/src/database/models.py index 7496ade77..5a904db2e 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -95,6 +95,7 @@ class Edition(Base): edition_id = Column(Integer, primary_key=True) name: str = Column(Text, unique=True, nullable=False) year: int = Column(Integer, nullable=False) + readonly: bool = Column(Boolean, nullable=False, default=False) invite_links: list[InviteLink] = relationship("InviteLink", back_populates="edition", cascade="all, delete-orphan") projects: list[Project] = relationship("Project", back_populates="edition", cascade="all, delete-orphan") @@ -134,6 +135,7 @@ class Project(Base): project_id = Column(Integer, primary_key=True) name = Column(Text, nullable=False) + info_url = Column(Text, nullable=True) edition_id = Column(Integer, ForeignKey("editions.edition_id")) edition: Edition = relationship("Edition", back_populates="projects", uselist=False, lazy="selectin") diff --git a/backend/tests/test_database/test_crud/test_projects.py b/backend/tests/test_database/test_crud/test_projects.py index d3742fd2b..c06c60b18 100644 --- a/backend/tests/test_database/test_crud/test_projects.py +++ b/backend/tests/test_database/test_crud/test_projects.py @@ -9,51 +9,6 @@ from src.database.models import Edition, Partner, Project, User, Skill, ProjectRole, Student, ProjectRoleSuggestion -@pytest.fixture -async def database_with_data(database_session: AsyncSession) -> AsyncSession: - """fixture for adding data to the database""" - edition: Edition = Edition(year=2022, name="ed2022") - database_session.add(edition) - user: User = User(name="coach1") - database_session.add(user) - project1 = Project(name="project1", edition=edition) - project2 = Project(name="project2", edition=edition) - project3 = Project(name="super nice project", edition=edition, coaches=[user]) - database_session.add(project1) - database_session.add(project2) - database_session.add(project3) - skill1: Skill = Skill(name="skill1") - skill2: Skill = Skill(name="skill2") - skill3: Skill = Skill(name="skill3") - database_session.add(skill1) - database_session.add(skill2) - database_session.add(skill3) - student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", - email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, - wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3]) - student02: Student = Student(first_name="Isabella", last_name="Christensen", preferred_name="Isabella", - email_address="isabella.christensen@example.com", phone_number="98389723", alumni=True, - wants_to_be_student_coach=True, edition=edition, skills=[skill2]) - project_role1: ProjectRole = ProjectRole( - student=student01, project=project1, skill=skill1, drafter=user, argumentation="argmunet") - project_role2: ProjectRole = ProjectRole( - student=student01, project=project2, skill=skill3, drafter=user, argumentation="argmunet") - project_role3: ProjectRole = ProjectRole( - student=student02, project=project1, skill=skill1, drafter=user, argumentation="argmunet") - database_session.add(project_role1) - database_session.add(project_role2) - database_session.add(project_role3) - await database_session.commit() - - return database_session - - -@pytest.fixture -async def current_edition(database_with_data: AsyncSession) -> Edition: - """fixture to get the latest edition""" - return (await database_with_data.execute(select(Edition))).scalars().all()[-1] - - async def test_get_all_projects_empty(database_session: AsyncSession): """test get all projects but there are none""" edition: Edition = Edition(year=2022, name="ed2022") @@ -138,6 +93,7 @@ async def test_add_project(database_session: AsyncSession): input_project: InputProject = InputProject( name="project 1", + info_url="https://info.com", partners=["partner1", "partner2"], coaches=[] ) @@ -146,6 +102,7 @@ async def test_add_project(database_session: AsyncSession): assert len((await database_session.execute(select(Project))).unique().scalars().all()) == 1 assert project.name == input_project.name + assert project.info_url == input_project.info_url assert len(project.partners) == len(partners) assert project.edition == edition diff --git a/backend/tests/test_database/test_crud/test_users.py b/backend/tests/test_database/test_crud/test_users.py index c3b683851..c3a336dfc 100644 --- a/backend/tests/test_database/test_crud/test_users.py +++ b/backend/tests/test_database/test_crud/test_users.py @@ -149,7 +149,7 @@ async def test_get_all_admins_paginated_filter_name(database_session: AsyncSessi DB_PAGE_SIZE * 1.5), 0) -async def test_get_user_edition_names_empty(database_session: AsyncSession): +async def test_get_user_editions_empty(database_session: AsyncSession): """Test getting all editions from a user when there are none""" user = models.User(name="test") database_session.add(user) @@ -158,11 +158,11 @@ async def test_get_user_edition_names_empty(database_session: AsyncSession): # query the user to initiate association tables await database_session.execute(select(models.User).where(models.User.user_id == user.user_id)) # No editions yet - editions = await users_crud.get_user_edition_names(database_session, user) + editions = await users_crud.get_user_editions(database_session, user) assert len(editions) == 0 -async def test_get_user_edition_names_admin(database_session: AsyncSession): +async def test_get_user_editions_admin(database_session: AsyncSession): """Test getting all editions for an admin""" user = models.User(name="test", admin=True) database_session.add(user) @@ -175,11 +175,11 @@ async def test_get_user_edition_names_admin(database_session: AsyncSession): await database_session.execute(select(models.User).where(models.User.user_id == user.user_id)) # Not added to edition yet, but admin can see it anyway - editions = await users_crud.get_user_edition_names(database_session, user) + editions = await users_crud.get_user_editions(database_session, user) assert len(editions) == 1 -async def test_get_user_edition_names_coach(database_session: AsyncSession): +async def test_get_user_editions_coach(database_session: AsyncSession): """Test getting all editions for a coach when they aren't empty""" user = models.User(name="test") database_session.add(user) @@ -192,7 +192,7 @@ async def test_get_user_edition_names_coach(database_session: AsyncSession): await database_session.execute(select(models.User).where(models.User.user_id == user.user_id)) # No editions yet - editions = await users_crud.get_user_edition_names(database_session, user) + editions = await users_crud.get_user_editions(database_session, user) assert len(editions) == 0 # Add user to a new edition @@ -200,9 +200,8 @@ async def test_get_user_edition_names_coach(database_session: AsyncSession): database_session.add(user) await database_session.commit() - # No editions yet - editions = await users_crud.get_user_edition_names(database_session, user) - assert editions == [edition.name] + editions = await users_crud.get_user_editions(database_session, user) + assert editions[0].name == edition.name async def test_get_all_users_from_edition(database_session: AsyncSession, data: dict[str, str]): diff --git a/backend/tests/test_routers/test_editions/test_editions/test_editions.py b/backend/tests/test_routers/test_editions/test_editions/test_editions.py index 792e396b7..a23dd5624 100644 --- a/backend/tests/test_routers/test_editions/test_editions/test_editions.py +++ b/backend/tests/test_routers/test_editions/test_editions/test_editions.py @@ -259,3 +259,18 @@ async def test_get_edition_by_name_coach_not_assigned(database_session: AsyncSes # Make the get request response = await auth_client.get(f"/editions/{edition2.name}") assert response.status_code == status.HTTP_403_FORBIDDEN + + +async def test_patch_edition(database_session: AsyncSession, auth_client: AuthClient): + """Test changing the status of an edition""" + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + await database_session.commit() + await auth_client.admin() + + async with auth_client: + response = await auth_client.patch(f"/editions/{edition.name}", json={"readonly": True}) + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = await auth_client.get(f"/editions/{edition.name}") + assert response.json()["readonly"] diff --git a/backend/tests/test_routers/test_editions/test_invites/test_invites.py b/backend/tests/test_routers/test_editions/test_invites/test_invites.py index 6cf1373ef..ffca102bc 100644 --- a/backend/tests/test_routers/test_editions/test_invites/test_invites.py +++ b/backend/tests/test_routers/test_editions/test_invites/test_invites.py @@ -182,10 +182,10 @@ async def test_get_invite_present(database_session: AsyncSession, auth_client: A assert json["email"] == "test@ema.il" -async def test_create_invite_valid_old_edition(database_session: AsyncSession, auth_client: AuthClient): +async def test_create_invite_valid_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): """Test endpoint for creating invites when data is valid, but the edition is read-only""" await auth_client.admin() - edition = Edition(year=2022, name="ed2022") + edition = Edition(year=2022, name="ed2022", readonly=True) edition2 = Edition(year=2023, name="ed2023") database_session.add(edition) database_session.add(edition2) diff --git a/backend/tests/test_routers/test_editions/test_projects/test_projects.py b/backend/tests/test_routers/test_editions/test_projects/test_projects.py index 9e96a8f3a..8ae217519 100644 --- a/backend/tests/test_routers/test_editions/test_projects/test_projects.py +++ b/backend/tests/test_routers/test_editions/test_projects/test_projects.py @@ -55,7 +55,7 @@ async def test_delete_project(database_session: AsyncSession, auth_client: AuthC await auth_client.admin() endpoint = f"/editions/{edition.name}/projects/{project.project_id}" - + async with auth_client: response = await auth_client.get(endpoint) assert response.status_code == status.HTTP_200_OK @@ -90,14 +90,16 @@ async def test_create_project(database_session: AsyncSession, auth_client: AuthC async with auth_client: response = await auth_client.post("/editions/ed2022/projects", json={ "name": "test", + "info_url": "https://info.com", "partners": ["ugent"], "coaches": [user.user_id] }) - + assert response.status_code == status.HTTP_201_CREATED json: dict = response.json() assert "projectId" in json assert json["name"] == "test" + assert json["infoUrl"] == "https://info.com" assert json["partners"][0]["name"] == "ugent" assert json["coaches"][0]["name"] == user.name assert len(json["projectRoles"]) == 0 @@ -115,14 +117,15 @@ async def test_create_project_same_partner(database_session: AsyncSession, auth_ await auth_client.admin() async with auth_client: - await auth_client.post(f"/editions/{edition.name}/projects", json={ "name": "test", + "info_url": "https://info.com", "partners": ["ugent"], "coaches": [user.user_id] }) await auth_client.post(f"/editions/{edition.name}/projects", json={ "name": "test", + "info_url": "https://info.com", "partners": ["ugent"], "coaches": [user.user_id] }) @@ -143,12 +146,12 @@ async def test_create_project_non_existing_coach(database_session: AsyncSession, async with auth_client: response = await auth_client.post(endpoint, json={ "name": "test", + "info_url": "https://info.com", "partners": ["ugent"], "coaches": [0] }) assert response.status_code == status.HTTP_404_NOT_FOUND - response = await auth_client.get(f"/editions/{edition.name}/projects/") assert len(response.json()['projects']) == 0 @@ -164,12 +167,62 @@ async def test_create_project_no_name(database_session: AsyncSession, auth_clien await database_session.begin_nested() async with auth_client: response = await auth_client.post(f"/editions/{edition.name}/projects", json={ + "info_url": "https://info.com", + "partners": [], + "coaches": [] + }) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response = await auth_client.get(f"/editions/{edition.name}/projects/", follow_redirects=True) + assert len(response.json()['projects']) == 0 + + +async def test_create_project_no_input_url(database_session: AsyncSession, auth_client: AuthClient): + """Tests creating a project that has no name""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + await database_session.commit() + + await auth_client.admin() + + await database_session.begin_nested() + async with auth_client: + response = await auth_client.post(f"/editions/{edition.name}/projects", json={ + "name": "test", + "partners": [], + "coaches": [] + }) + + assert response.status_code == status.HTTP_201_CREATED + json: dict = response.json() + assert "projectId" in json + assert json["name"] == "test" + assert json["infoUrl"] is None + assert len(json["partners"]) == 0 + assert len(json["coaches"]) == 0 + assert len(json["projectRoles"]) == 0 + + +async def test_create_project_invalid_input_url(database_session: AsyncSession, auth_client: AuthClient): + """Tests creating a project that has no name""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + await database_session.commit() + + await auth_client.admin() + + await database_session.begin_nested() + async with auth_client: + response = await auth_client.post(f"/editions/{edition.name}/projects", json={ + "name": "test", + "info_url": "ssh://info.com", "partners": [], "coaches": [] }) - + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - + response = await auth_client.get(f"/editions/{edition.name}/projects/", follow_redirects=True) assert len(response.json()['projects']) == 0 @@ -253,9 +306,9 @@ async def test_patch_wrong_project(database_session: AsyncSession, auth_client: assert json['projects'][0]['name'] == project.name -async def test_create_project_old_edition(database_session: AsyncSession, auth_client: AuthClient): +async def test_create_project_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): """test create a project for a readonly edition""" - edition_22: Edition = Edition(year=2022, name="ed2022") + edition_22: Edition = Edition(year=2022, name="ed2022", readonly=True) edition_23: Edition = Edition(year=2023, name="ed2023") database_session.add(edition_22) database_session.add(edition_23) diff --git a/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py b/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py index ee557b520..2dffa51c9 100644 --- a/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py +++ b/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py @@ -97,9 +97,9 @@ async def test_add_pr_suggestion_non_existing_pr(database_session: AsyncSession, assert len((await database_session.execute(select(ProjectRoleSuggestion))).scalars().all()) == 0 -async def test_add_pr_suggestion_old_edition(database_session: AsyncSession, auth_client: AuthClient): +async def test_add_pr_suggestion_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): """tests add a student to a project from an old edition""" - edition: Edition = Edition(year=2022, name="ed2022") + edition: Edition = Edition(year=2022, name="ed2022", readonly=True) project: Project = Project(name="project 1", edition=edition) skill: Skill = Skill(name="skill 1") project_role: ProjectRole = ProjectRole(project=project, skill=skill, slots=1) diff --git a/backend/tests/test_routers/test_editions/test_register/test_register.py b/backend/tests/test_routers/test_editions/test_register/test_register.py index bb6fd9462..af0546bb3 100644 --- a/backend/tests/test_routers/test_editions/test_register/test_register.py +++ b/backend/tests/test_routers/test_editions/test_register/test_register.py @@ -101,9 +101,9 @@ async def test_duplicate_user(database_session: AsyncSession, test_client: Async assert response.status_code == status.HTTP_409_CONFLICT -async def test_old_edition(database_session: AsyncSession, test_client: AsyncClient): +async def test_readonly_edition(database_session: AsyncSession, test_client: AsyncClient): """Tests trying to make a registration for a read-only edition""" - edition: Edition = Edition(year=2022, name="ed2022") + edition: Edition = Edition(year=2022, name="ed2022", readonly=True) edition3: Edition = Edition(year=2023, name="ed2023") invite_link: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") diff --git a/backend/tests/test_routers/test_editions/test_students/test_students.py b/backend/tests/test_routers/test_editions/test_students/test_students.py index 541948a35..33ef33d5c 100644 --- a/backend/tests/test_routers/test_editions/test_students/test_students.py +++ b/backend/tests/test_routers/test_editions/test_students/test_students.py @@ -587,8 +587,11 @@ async def test_creat_email_for_ghost(database_with_data: AsyncSession, auth_clie assert response.status_code == status.HTTP_404_NOT_FOUND -async def test_creat_email_student_in_other_edition(database_with_data: AsyncSession, auth_client: AuthClient): - """test creat an email for a student not in this edition""" +async def test_create_email_student_in_other_edition_bulk(database_with_data: AsyncSession, auth_client: AuthClient): + """test creating an email for a student not in this edition when sending them in bulk + The expected result is that only the mails to students in that edition are sent, and the + others are ignored + """ edition: Edition = Edition(year=2023, name="ed2023") database_with_data.add(edition) student: Student = Student(first_name="Mehmet", last_name="Dizdar", preferred_name="Mehmet", @@ -599,7 +602,29 @@ async def test_creat_email_student_in_other_edition(database_with_data: AsyncSes await auth_client.admin() async with auth_client: response = await auth_client.post("/editions/ed2022/students/emails", - json={"students_id": [3], "email_status": 5}) + json={"students_id": [1, student.student_id], "email_status": 5}) + + # When sending a request for students that aren't in this edition, + # it ignores them & creates emails for the rest instead + assert response.status_code == status.HTTP_201_CREATED + assert len(response.json()["studentEmails"]) == 1 + assert response.json()["studentEmails"][0]["student"]["studentId"] == 1 + + +async def test_create_emails_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): + """Test sending emails in a readonly edition""" + edition: Edition = Edition(year=2023, name="ed2023", readonly=True) + database_session.add(edition) + student: Student = Student(first_name="Mehmet", last_name="Dizdar", preferred_name="Mehmet", + email_address="mehmet.dizdar@example.com", phone_number="(787)-938-6216", alumni=True, + wants_to_be_student_coach=False, edition=edition, skills=[]) + database_session.add(student) + await database_session.commit() + await auth_client.admin() + + async with auth_client: + response = await auth_client.post(f"/editions/{edition.name}/students/emails", + json={"students_id": [student.student_id], "email_status": 5}) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py b/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py index 793889951..debdc7389 100644 --- a/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py +++ b/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py @@ -74,6 +74,26 @@ async def test_new_suggestion(database_with_data: AsyncSession, auth_client: Aut "suggestion"]["argumentation"] == suggestions[0].argumentation +async def test_new_suggestion_readonly_edition(database_session: AsyncSession, auth_client: AuthClient): + """Tests creating a new suggestion when the edition is read-only""" + edition = Edition(year=2022, name="ed2022", readonly=True) + await auth_client.admin() + + student: Student = Student(first_name="Marta", last_name="Marquez", preferred_name="Marta", + email_address="marta.marquez@example.com", phone_number="967-895-285", alumni=False, + decision=DecisionEnum.YES, wants_to_be_student_coach=False, edition=edition, + skills=[]) + + database_session.add(edition) + database_session.add(student) + await database_session.commit() + + async with auth_client: + response = await auth_client.post(f"/editions/{edition.name}/students/{student.student_id}/suggestions", + json={"suggestion": 1, "argumentation": "test"}) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + async def test_overwrite_suggestion(database_with_data: AsyncSession, auth_client: AuthClient): """Tests that when you've already made a suggestion earlier, the existing one is replaced""" # Create initial suggestion @@ -155,8 +175,8 @@ async def test_delete_ghost_suggestion(database_with_data: AsyncSession, auth_cl "/editions/ed2022/students/1/suggestions/8000")).status_code == status.HTTP_404_NOT_FOUND -async def test_delete_not_autorized(database_with_data: AsyncSession, auth_client: AuthClient): - """Tests that you have to be loged in for deleating a suggestion""" +async def test_delete_not_authorized(database_with_data: AsyncSession, auth_client: AuthClient): + """Tests that you have to be logged in in order to delete a suggestion""" async with auth_client: assert (await auth_client.delete( "/editions/ed2022/students/1/suggestions/8000")).status_code == status.HTTP_401_UNAUTHORIZED diff --git a/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py b/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py index 9bd96f440..0dd80a7ee 100644 --- a/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py +++ b/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py @@ -122,7 +122,8 @@ async def test_webhook_missing_question(test_client: AsyncClient, webhook: Webho assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY -async def test_new_webhook_old_edition(database_session: AsyncSession, auth_client: AuthClient, edition: Edition): +async def test_new_webhook_readonly_edition(database_session: AsyncSession, auth_client: AuthClient, edition: Edition): + edition.readonly = True database_session.add(Edition(year=2023, name="ed2023")) await database_session.commit() async with auth_client: diff --git a/backend/tests/test_routers/test_users/test_users.py b/backend/tests/test_routers/test_users/test_users.py index 2f7c1420a..b4a71dcb2 100644 --- a/backend/tests/test_routers/test_users/test_users.py +++ b/backend/tests/test_routers/test_users/test_users.py @@ -6,7 +6,7 @@ from settings import DB_PAGE_SIZE from src.database import models -from src.database.models import user_editions, CoachRequest +from src.database.models import user_editions, CoachRequest, Edition, User from tests.utils.authorization import AuthClient @@ -721,3 +721,20 @@ async def test_reject_request(database_session: AsyncSession, auth_client: AuthC response = await auth_client.post("users/requests/INVALID/reject") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_get_current_user(database_session: AsyncSession, auth_client: AuthClient): + """Test getting the current user from their access token""" + edition = Edition(year=2022, name="ed2022") + user = User(name="Pytest Admin", admin=True, editions=[edition]) + database_session.add(edition) + database_session.add(user) + await database_session.commit() + auth_client.login(user) + + async with auth_client: + response = await auth_client.get("/users/current") + assert response.status_code == status.HTTP_200_OK + assert response.json()["userId"] == auth_client.user.user_id + assert len(response.json()["editions"]) == 1 + assert response.json()["editions"][0]["name"] == edition.name diff --git a/backend/tests/test_schemas/test_validators.py b/backend/tests/test_schemas/test_validators.py index 0b2da8b97..d68d03a0c 100644 --- a/backend/tests/test_schemas/test_validators.py +++ b/backend/tests/test_schemas/test_validators.py @@ -1,7 +1,7 @@ import pytest from src.app.exceptions.validation_exception import ValidationException -from src.app.schemas.validators import validate_email_format, validate_edition +from src.app.schemas.validators import validate_email_format, validate_edition, validate_url def test_email_address(): @@ -42,3 +42,15 @@ def test_edition_name(): validate_edition("edition2022") validate_edition("Edition-2022") + + +def test_validate_url(): + """Test the validation of an url""" + validate_url("https://info.com") + validate_url("http://info") + with pytest.raises(ValidationException): + validate_url("ssh://info.com") + with pytest.raises(ValidationException): + validate_url("http:/info.com") + with pytest.raises(ValidationException): + validate_url("https:/info.com") diff --git a/frontend/src/components/Common/Buttons/OrangeButton.tsx b/frontend/src/components/Common/Buttons/OrangeButton.tsx new file mode 100644 index 000000000..5924e338c --- /dev/null +++ b/frontend/src/components/Common/Buttons/OrangeButton.tsx @@ -0,0 +1,19 @@ +import { BasicButton } from "./props"; +import { OrangeButton as StyledOrangeButton } from "./styles"; + +/** + * Orange button + */ +export default function OrangeButton({ + label = "", + showIcon = false, + children, + ...props +}: BasicButton) { + return ( + + {children} + {label} + + ); +} diff --git a/frontend/src/components/Common/Buttons/index.ts b/frontend/src/components/Common/Buttons/index.ts index 502fca43f..85cf45cb7 100644 --- a/frontend/src/components/Common/Buttons/index.ts +++ b/frontend/src/components/Common/Buttons/index.ts @@ -1,3 +1,4 @@ export { default as CreateButton } from "./CreateButton"; export { default as DeleteButton } from "./DeleteButton"; +export { default as OrangeButton } from "./OrangeButton"; export { default as WarningButton } from "./WarningButton"; diff --git a/frontend/src/components/Common/Buttons/styles.ts b/frontend/src/components/Common/Buttons/styles.ts index 468fda708..3fc91679b 100644 --- a/frontend/src/components/Common/Buttons/styles.ts +++ b/frontend/src/components/Common/Buttons/styles.ts @@ -28,6 +28,23 @@ export const GreenButton = styled(Button)` } `; +export const OrangeButton = styled(Button)` + ${HoverAnimation}; + + background-color: var(--osoc_orange); + border-color: var(--osoc_orange); + color: var(--osoc_blue); + + &:hover, + &:active, + &:focus { + background-color: var(--osoc_orange_darkened); + border-color: var(--osoc_orange_darkened); + color: var(--osoc_blue); + box-shadow: none !important; + } +`; + export const DropdownToggle = styled(Dropdown.Toggle)` ${HoverAnimation}; diff --git a/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx b/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx index bd9c8ee99..be2d5a749 100644 --- a/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx +++ b/frontend/src/components/CurrentEditionRoute/CurrentEditionRoute.tsx @@ -1,16 +1,17 @@ import { Navigate, Outlet, useParams } from "react-router-dom"; -import { useAuth } from "../../contexts/auth-context"; +import { useAuth } from "../../contexts"; import { Role } from "../../data/enums"; +import { isReadonlyEdition } from "../../utils/logic"; /** - * React component for current edition and admin-only routes. + * React component for editable editions and admin-only routes. * Redirects to the [[LoginPage]] (status 401) if not authenticated, - * and to the [[ForbiddenPage]] (status 403) if not admin or not the current edition. + * and to the [[ForbiddenPage]] (status 403) if not admin or read-only. * * Example usage: * ```ts * }> - * // These routes will only render if the user is an admin and is on the current edition + * // These routes will only render if the user is an admin and is not on a read-only edition * * * @@ -22,7 +23,7 @@ export default function CurrentEditionRoute() { const editionId = params.editionId; return !isLoggedIn ? ( - ) : role === Role.COACH || editionId !== editions[0] ? ( + ) : role === Role.COACH || isReadonlyEdition(editionId, editions) ? ( ) : ( diff --git a/frontend/src/components/EditionsPage/DeleteEditionButton.tsx b/frontend/src/components/EditionsPage/DeleteEditionButton.tsx index 298464b7e..ca23830ad 100644 --- a/frontend/src/components/EditionsPage/DeleteEditionButton.tsx +++ b/frontend/src/components/EditionsPage/DeleteEditionButton.tsx @@ -26,7 +26,7 @@ export default function DeleteEditionButton(props: Props) { } return ( - + Delete this edition diff --git a/frontend/src/components/EditionsPage/EditionRow.tsx b/frontend/src/components/EditionsPage/EditionRow.tsx index aba87e499..18fd757ee 100644 --- a/frontend/src/components/EditionsPage/EditionRow.tsx +++ b/frontend/src/components/EditionsPage/EditionRow.tsx @@ -1,9 +1,13 @@ import { Edition } from "../../data/interfaces"; import DeleteEditionButton from "./DeleteEditionButton"; import { RowContainer } from "./styles"; +import MarkReadonlyButton from "./MarkReadonlyButton"; +import React from "react"; +import Col from "react-bootstrap/Col"; interface Props { edition: Edition; + handleClick: (edition: Edition) => Promise; } /** @@ -13,11 +17,21 @@ export default function EditionRow(props: Props) { return ( -
-

{props.edition.name}

- {props.edition.year} -
- + +
+

{props.edition.name}

+ {props.edition.year} +
+ + + await props.handleClick(props.edition)} + /> + + + +
); diff --git a/frontend/src/components/EditionsPage/EditionsTable.tsx b/frontend/src/components/EditionsPage/EditionsTable.tsx index c4284f66b..154a36537 100644 --- a/frontend/src/components/EditionsPage/EditionsTable.tsx +++ b/frontend/src/components/EditionsPage/EditionsTable.tsx @@ -1,8 +1,12 @@ import React, { useEffect, useState } from "react"; -import { StyledTable, LoadingSpinner } from "./styles"; -import { getEditions } from "../../utils/api/editions"; +import { LoadingSpinner, StyledTable } from "./styles"; +import { getEditions, patchEdition } from "../../utils/api/editions"; import EditionRow from "./EditionRow"; import EmptyEditionsTableMessage from "./EmptyEditionsTableMessage"; +import { Edition } from "../../data/interfaces"; +import { toast } from "react-toastify"; +import { updateEditionState, useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; /** * Table on the [[EditionsPage]] that renders a list of all editions @@ -11,14 +15,30 @@ import EmptyEditionsTableMessage from "./EmptyEditionsTableMessage"; * If the user is an admin, this will also render a delete button. */ export default function EditionsTable() { + const authCtx = useAuth(); const [loading, setLoading] = useState(true); const [rows, setRows] = useState([]); + async function handleClick(edition: Edition) { + if (authCtx.role !== Role.ADMIN) return; + + await toast.promise(async () => await patchEdition(edition.name, !edition.readonly), { + pending: "Changing edition status", + error: "Error changing status", + success: `Successfully changed status to ${ + edition.readonly ? "editable" : "read-only" + }.`, + }); + + updateEditionState(authCtx, edition); + await loadEditions(); + } + async function loadEditions() { const response = await getEditions(); const newRows: React.ReactNode[] = response.editions.map(edition => ( - + )); setRows(newRows); @@ -27,6 +47,7 @@ export default function EditionsTable() { useEffect(() => { loadEditions(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Still loading: display a spinner instead diff --git a/frontend/src/components/EditionsPage/MarkReadonlyButton.tsx b/frontend/src/components/EditionsPage/MarkReadonlyButton.tsx new file mode 100644 index 000000000..bb43e7e28 --- /dev/null +++ b/frontend/src/components/EditionsPage/MarkReadonlyButton.tsx @@ -0,0 +1,28 @@ +import { Edition } from "../../data/interfaces"; +import { StyledReadonlyText } from "./styles"; +import { useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; + +interface Props { + edition: Edition; + handleClick: () => void; +} + +/** + * Button on the [[EditionsPage]], displayed in an [[EditionsRow]], to toggle the readonly + * state of an edition. + */ +export default function MarkReadonlyButton({ edition, handleClick }: Props) { + const { role } = useAuth(); + const label = edition.readonly ? "READ-ONLY" : "EDITABLE"; + + return ( + + {label} + + ); +} diff --git a/frontend/src/components/EditionsPage/styles.ts b/frontend/src/components/EditionsPage/styles.ts index adc52230a..34f30c3c5 100644 --- a/frontend/src/components/EditionsPage/styles.ts +++ b/frontend/src/components/EditionsPage/styles.ts @@ -37,3 +37,27 @@ export const StyledNewEditionButton = styled(Button).attrs(() => ({ border-color: var(--osoc_orange); } `; + +interface TextProps { + readonly: boolean; + clickable: boolean; +} + +export const StyledReadonlyText = styled.div` + text-decoration: none; + transition: 200ms ease-out; + color: ${props => (props.readonly ? "var(--osoc_red)" : "var(--osoc_green)")}; + font-weight: bold; + + // Only change style on hover for admins + ${({ clickable }) => + clickable && + ` + &:hover { + text-decoration: underline; + transition: 200ms ease-out; + color: var(--osoc_orange); + cursor: pointer; + } + `}; +`; diff --git a/frontend/src/components/Navbar/EditionDropdown.tsx b/frontend/src/components/Navbar/EditionDropdown.tsx index c6e7ceb78..1346cd36b 100644 --- a/frontend/src/components/Navbar/EditionDropdown.tsx +++ b/frontend/src/components/Navbar/EditionDropdown.tsx @@ -4,9 +4,10 @@ import { StyledDropdownItem } from "./styles"; import { useLocation, useNavigate } from "react-router-dom"; import { getCurrentEdition, setCurrentEdition } from "../../utils/session-storage"; import { getBestRedirect } from "../../utils/logic"; +import { Edition } from "../../data/interfaces"; interface Props { - editions: string[]; + editions: Edition[]; } /** @@ -27,7 +28,7 @@ export default function EditionDropdown(props: Props) { // found in the list of editions // This shouldn't happen, but just in case // The list can never be empty because then we return null above ^ - const currentEdition = getCurrentEdition() || props.editions[0]; + const currentEdition = getCurrentEdition() || props.editions[0].name; /** * Change the route based on the edition @@ -42,14 +43,14 @@ export default function EditionDropdown(props: Props) { } // Load dropdown items dynamically - props.editions.forEach((edition: string) => { + props.editions.forEach((edition: Edition) => { navItems.push( handleSelect(edition)} + key={edition.name} + active={currentEdition === edition.name} + onClick={() => handleSelect(edition.name)} > - {edition} + {edition.name} ); }); diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx index 012153f29..3b777bc2f 100644 --- a/frontend/src/components/Navbar/Navbar.tsx +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -42,7 +42,7 @@ export default function Navbar() { // Matched /editions/new path if (editionId === "new") { editionId = null; - } else if (editionId && !editions.includes(editionId)) { + } else if (editionId && !editions.find(e => e.name === editionId)) { // If the edition was not found in the user's list of editions, // don't display it in the navbar! // This will lead to a 404 or 403 re-route either way, so keep @@ -53,7 +53,7 @@ export default function Navbar() { // If the current URL contains an edition, use that // if not (eg. /editions), check SessionStorage // otherwise, use the most-recent edition from the auth response - const currentEdition = editionId || getCurrentEdition() || editions[0]; + const currentEdition = editionId || getCurrentEdition() || editions[0].name; // Set the value of the new edition in SessionStorage if useful if (currentEdition) { diff --git a/frontend/src/contexts/auth-context.tsx b/frontend/src/contexts/auth-context.tsx index bfd2a818d..5e7ccc46f 100644 --- a/frontend/src/contexts/auth-context.tsx +++ b/frontend/src/contexts/auth-context.tsx @@ -1,7 +1,7 @@ /** Context hook to maintain the authentication state of the user **/ import { Role } from "../data/enums"; import React, { useContext, ReactNode, useState } from "react"; -import { User } from "../data/interfaces"; +import { Edition, User } from "../data/interfaces"; import { setCurrentEdition } from "../utils/session-storage"; import { setAccessToken, setRefreshToken } from "../utils/local-storage"; @@ -15,8 +15,8 @@ export interface AuthContextState { setRole: (value: Role | null) => void; userId: number | null; setUserId: (value: number | null) => void; - editions: string[]; - setEditions: (value: string[]) => void; + editions: Edition[]; + setEditions: (value: Edition[]) => void; } /** @@ -33,7 +33,7 @@ function authDefaultState(): AuthContextState { userId: null, setUserId: (_: number | null) => {}, editions: [], - setEditions: (_: string[]) => {}, + setEditions: (_: Edition[]) => {}, }; } @@ -56,7 +56,7 @@ export function useAuth(): AuthContextState { export function AuthProvider({ children }: { children: ReactNode }) { const [isLoggedIn, setIsLoggedIn] = useState(null); const [role, setRole] = useState(null); - const [editions, setEditions] = useState([]); + const [editions, setEditions] = useState([]); const [userId, setUserId] = useState(null); // Create AuthContext value @@ -100,3 +100,18 @@ export function logOut(authContext: AuthContextState) { // Remove current edition from SessionStorage setCurrentEdition(null); } + +/** + * Update the state of an edition in the AuthContext + */ +export function updateEditionState(authContext: AuthContextState, edition: Edition) { + const index = authContext.editions.findIndex(e => e.name === edition.name); + if (index === -1) return; + + // Flip the state of the element + const copy = [...authContext.editions]; + copy[index].readonly = !copy[index].readonly; + + // Call the setter to update the state + authContext.setEditions(copy); +} diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts index 133c24ae4..f37fd4506 100644 --- a/frontend/src/contexts/index.ts +++ b/frontend/src/contexts/index.ts @@ -1,3 +1,3 @@ import type { AuthContextState } from "./auth-context"; export type { AuthContextState }; -export { AuthProvider, logIn, logOut, useAuth } from "./auth-context"; +export { AuthProvider, logIn, logOut, useAuth, updateEditionState } from "./auth-context"; diff --git a/frontend/src/data/interfaces/editions.ts b/frontend/src/data/interfaces/editions.ts index db1e88e65..6fbebd074 100644 --- a/frontend/src/data/interfaces/editions.ts +++ b/frontend/src/data/interfaces/editions.ts @@ -4,4 +4,5 @@ export interface Edition { name: string; year: number; + readonly: boolean; } diff --git a/frontend/src/data/interfaces/users.ts b/frontend/src/data/interfaces/users.ts index 1c653263d..7622c7f0e 100644 --- a/frontend/src/data/interfaces/users.ts +++ b/frontend/src/data/interfaces/users.ts @@ -1,3 +1,5 @@ +import { Edition } from "./editions"; + /** * Data about a user using the application. * Contains a list of edition names so that we can quickly check if @@ -7,5 +9,5 @@ export interface User { userId: number; name: string; admin: boolean; - editions: string[]; + editions: Edition[]; } diff --git a/frontend/src/utils/api/editions.ts b/frontend/src/utils/api/editions.ts index c4947052d..6b58d2527 100644 --- a/frontend/src/utils/api/editions.ts +++ b/frontend/src/utils/api/editions.ts @@ -6,11 +6,6 @@ interface EditionsResponse { editions: Edition[]; } -interface EditionFields { - name: string; - year: number; -} - /** * Get all editions the user can see. */ @@ -20,9 +15,9 @@ export async function getEditions(): Promise { } /** - * Get all edition names sorted the user can see + * Get all edition names sorted that the user can see */ -export async function getSortedEditions(): Promise { +export async function getSortedEditions(): Promise { const response = await axiosInstance.get("/users/current"); return response.data.editions; } @@ -39,7 +34,7 @@ export async function deleteEdition(name: string): Promise { * Create a new edition with the given name and year */ export async function createEdition(name: string, year: number): Promise { - const payload: EditionFields = { name: name, year: year }; + const payload = { name: name, year: year }; try { return await axiosInstance.post("/editions", payload); } catch (error) { @@ -50,3 +45,11 @@ export async function createEdition(name: string, year: number): Promise { + const payload = { readonly: readonly }; + return await axiosInstance.patch(`/editions/${name}`, payload); +} diff --git a/frontend/src/utils/logic/editions.ts b/frontend/src/utils/logic/editions.ts new file mode 100644 index 000000000..bc3c17c5f --- /dev/null +++ b/frontend/src/utils/logic/editions.ts @@ -0,0 +1,9 @@ +import { Edition } from "../../data/interfaces"; + +/** + * Check if an edition is read-only + */ +export function isReadonlyEdition(name: string | undefined, editions: Edition[]): boolean { + if (!name) return false; + return editions.find(e => e.name === name)?.readonly || false; +} diff --git a/frontend/src/utils/logic/index.ts b/frontend/src/utils/logic/index.ts index 4e45eae5c..1f8a18d35 100644 --- a/frontend/src/utils/logic/index.ts +++ b/frontend/src/utils/logic/index.ts @@ -1,2 +1,3 @@ +export { isReadonlyEdition } from "./editions"; export { createRedirectUri, decodeRegistrationLink } from "./registration"; export { getBestRedirect } from "./routes"; diff --git a/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx b/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx index 7fe749298..ebb80a8ec 100644 --- a/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx @@ -8,6 +8,7 @@ import { useAuth } from "../../../contexts"; import { Role } from "../../../data/enums"; import ConflictsButton from "../../../components/ProjectsComponents/Conflicts/ConflictsButton"; +import { isReadonlyEdition } from "../../../utils/logic"; import { toast } from "react-toastify"; /** * @returns The projects overview page where you can see all the projects. @@ -144,7 +145,7 @@ export default function ProjectPage() { placeholder="project name" /> - {role === Role.ADMIN && editionId === editions[0] && ( + {role === Role.ADMIN && !isReadonlyEdition(editionId, editions) && ( navigate("/editions/" + editionId + "/projects/new")} >