Skip to content

Commit

Permalink
Schools (#641)
Browse files Browse the repository at this point in the history
### Description

Add schools system to differentiate users

### Checklist

- [x] Created tests which fail without the change (if possible)
- [x] All tests passing
- [x] Extended the documentation, if necessary

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
  • Loading branch information
Rotheem and github-advanced-security[bot] authored Jan 14, 2025
1 parent 535e18f commit a8a8097
Show file tree
Hide file tree
Showing 34 changed files with 1,313 additions and 238 deletions.
2 changes: 2 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.core.groups import endpoints_groups
from app.core.notification import endpoints_notification
from app.core.payment import endpoints_payment
from app.core.schools import endpoints_schools
from app.core.users import endpoints_users
from app.modules.module_list import module_list

Expand All @@ -24,6 +25,7 @@
api_router.include_router(endpoints_notification.router)
api_router.include_router(endpoints_payment.router)
api_router.include_router(endpoints_users.router)
api_router.include_router(endpoints_schools.router)

for module in module_list:
api_router.include_router(module.router)
31 changes: 31 additions & 0 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from app.core.google_api.google_api import GoogleAPI
from app.core.groups.groups_type import GroupType
from app.core.log import LogConfig
from app.core.schools.schools_type import SchoolType
from app.dependencies import (
get_db,
get_redis_client,
Expand Down Expand Up @@ -189,6 +190,32 @@ def initialize_groups(
)


def initialize_schools(
sync_engine: Engine,
hyperion_error_logger: logging.Logger,
) -> None:
"""Add the necessary shools"""

hyperion_error_logger.info("Startup: Adding new groups to the database")
with Session(sync_engine) as db:
for school in SchoolType:
exists = initialization.get_school_by_id_sync(school_id=school.value, db=db)
# We don't want to recreate the groups if they already exist
if not exists:
db_school = models_core.CoreSchool(
id=school.value,
name=school.name,
email_regex="null",
)

try:
initialization.create_school_sync(school=db_school, db=db)
except IntegrityError as error:
hyperion_error_logger.fatal(
f"Startup: Could not add school {db_school.name}<{db_school.id}> in the database: {error}",
)


def initialize_module_visibility(
sync_engine: Engine,
hyperion_error_logger: logging.Logger,
Expand Down Expand Up @@ -301,6 +328,10 @@ def init_db(
sync_engine=sync_engine,
hyperion_error_logger=hyperion_error_logger,
)
initialize_schools(
sync_engine=sync_engine,
hyperion_error_logger=hyperion_error_logger,
)
initialize_module_visibility(
sync_engine=sync_engine,
hyperion_error_logger=hyperion_error_logger,
Expand Down
4 changes: 2 additions & 2 deletions app/core/auth/endpoints_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from app.types.exceptions import AuthHTTPException
from app.types.scopes_type import ScopeType
from app.utils.auth.providers import BaseAuthClient
from app.utils.tools import is_user_member_of_an_allowed_group
from app.utils.tools import is_user_member_of_any_group

router = APIRouter(tags=["Auth"])

Expand Down Expand Up @@ -308,7 +308,7 @@ async def authorize_validation(
# The auth_client may restrict the usage of the client to specific Hyperion groups.
# For example, only ECLAIR members may be allowed to access the wiki
if auth_client.allowed_groups is not None:
if not is_user_member_of_an_allowed_group(
if not is_user_member_of_any_group(
user=user,
allowed_groups=auth_client.allowed_groups,
):
Expand Down
4 changes: 3 additions & 1 deletion app/core/groups/groups_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class AccountType(str, Enum):
staff = "staff"
association = "association"
external = "external"
other_school_student = "other_school_student"
demo = "demo"

def __str__(self):
Expand All @@ -57,11 +58,12 @@ def get_ecl_account_types() -> list[AccountType]:
]


def get_account_types_except_external() -> list[AccountType]:
def get_account_types_except_externals() -> list[AccountType]:
return [
AccountType.student,
AccountType.former_student,
AccountType.staff,
AccountType.association,
AccountType.demo,
AccountType.other_school_student,
]
15 changes: 15 additions & 0 deletions app/core/models_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Common model files for all core in order to avoid circular import due to bidirectional relationship"""

from datetime import date, datetime
from uuid import UUID

from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
Expand Down Expand Up @@ -29,6 +30,7 @@ class CoreUser(Base):
index=True,
) # Use UUID later
email: Mapped[str] = mapped_column(unique=True, index=True)
school_id: Mapped[UUID] = mapped_column(ForeignKey("core_school.id"))
password_hash: Mapped[str]
# Depending on the account type, the user may have different rights and access to different features
# External users may exist for:
Expand All @@ -54,6 +56,11 @@ class CoreUser(Base):
lazy="selectin",
default_factory=list,
)
school: Mapped["CoreSchool"] = relationship(
"CoreSchool",
lazy="selectin",
init=False,
)


class CoreUserUnconfirmed(Base):
Expand Down Expand Up @@ -118,6 +125,14 @@ class CoreGroup(Base):
)


class CoreSchool(Base):
__tablename__ = "core_school"

id: Mapped[PrimaryKey]
name: Mapped[str] = mapped_column(unique=True)
email_regex: Mapped[str]


class CoreAssociationMembership(Base):
__tablename__ = "core_association_membership"

Expand Down
68 changes: 44 additions & 24 deletions app/core/schemas_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Common schemas file for endpoint /users et /groups because it would cause circular import"""

from datetime import date, datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field
from pydantic.functional_validators import field_validator
Expand All @@ -19,6 +20,44 @@ class CoreInformation(BaseModel):
minimal_titan_version_code: int


class CoreGroupBase(BaseModel):
"""Base schema for group's model"""

name: str
description: str | None = None

_normalize_name = field_validator("name")(validators.trailing_spaces_remover)


class CoreGroupSimple(CoreGroupBase):
"""Simplified schema for group's model, used when getting all groups"""

id: str
model_config = ConfigDict(from_attributes=True)


class CoreSchoolBase(BaseModel):
"""Schema for school's model"""

name: str
email_regex: str

_normalize_name = field_validator("name")(validators.trailing_spaces_remover)


class CoreSchool(CoreSchoolBase):
id: UUID


class CoreSchoolUpdate(BaseModel):
"""Schema for school update"""

name: str | None = None
email_regex: str | None = None

_normalize_name = field_validator("name")(validators.trailing_spaces_remover)


class CoreUserBase(BaseModel):
"""Base schema for user's model"""

Expand All @@ -35,41 +74,27 @@ class CoreUserBase(BaseModel):
)


class CoreGroupBase(BaseModel):
"""Base schema for group's model"""

name: str
description: str | None = None

_normalize_name = field_validator("name")(validators.trailing_spaces_remover)


class CoreUserSimple(CoreUserBase):
"""Simplified schema for user's model, used when getting all users"""

id: str
account_type: AccountType
model_config = ConfigDict(from_attributes=True)


class CoreGroupSimple(CoreGroupBase):
"""Simplified schema for group's model, used when getting all groups"""
school_id: UUID

id: str
model_config = ConfigDict(from_attributes=True)


class CoreUser(CoreUserSimple):
"""Schema for user's model similar to core_user table in database"""

email: str
account_type: AccountType
birthday: date | None = None
promo: int | None = None
floor: FloorsType | None = None
phone: str | None = None
created_on: datetime | None = None
groups: list[CoreGroupSimple] = []
school: CoreSchool | None = None


class CoreUserUpdate(BaseModel):
Expand Down Expand Up @@ -97,6 +122,8 @@ class CoreUserFusionRequest(BaseModel):


class CoreUserUpdateAdmin(BaseModel):
email: str | None = None
school_id: UUID | None = None
account_type: AccountType | None = None
name: str | None = None
firstname: str | None = None
Expand Down Expand Up @@ -164,7 +191,7 @@ class CoreUserActivateRequest(CoreUserBase):
floor: FloorsType | None = None
promo: int | None = Field(
default=None,
description="Promotion of the student, an integer like 21",
description="Promotion of the student, an integer like 2021",
)

# Password validator
Expand All @@ -189,13 +216,6 @@ class CoreGroupCreate(CoreGroupBase):
"""Model for group creation schema"""


class CoreGroupInDB(CoreGroupBase):
"""Schema for user activation"""

id: str
model_config = ConfigDict(from_attributes=True)


class CoreGroupUpdate(BaseModel):
"""Schema for group update"""

Expand Down
Empty file added app/core/schools/__init__.py
Empty file.
86 changes: 86 additions & 0 deletions app/core/schools/cruds_schools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""File defining the functions called by the endpoints, making queries to the table using the models"""

from collections.abc import Sequence
from uuid import UUID

from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import models_core, schemas_core


async def get_schools(db: AsyncSession) -> Sequence[models_core.CoreSchool]:
"""Return all schools from database"""

result = await db.execute(select(models_core.CoreSchool))
return result.scalars().all()


async def get_school_by_id(
db: AsyncSession,
school_id: UUID,
) -> schemas_core.CoreSchool | None:
"""Return school with id from database"""
result = (
(
await db.execute(
select(models_core.CoreSchool).where(
models_core.CoreSchool.id == school_id,
),
)
)
.scalars()
.first()
)
return (
schemas_core.CoreSchool(
name=result.name,
email_regex=result.email_regex,
id=result.id,
)
if result
else None
)


async def get_school_by_name(
db: AsyncSession,
school_name: str,
) -> models_core.CoreSchool | None:
"""Return school with name from database"""
result = await db.execute(
select(models_core.CoreSchool).where(
models_core.CoreSchool.name == school_name,
),
)
return result.scalars().first()


async def create_school(
school: models_core.CoreSchool,
db: AsyncSession,
) -> None:
"""Create a new school in database and return it"""

db.add(school)


async def delete_school(db: AsyncSession, school_id: UUID):
"""Delete a school from database by id"""

await db.execute(
delete(models_core.CoreSchool).where(models_core.CoreSchool.id == school_id),
)


async def update_school(
db: AsyncSession,
school_id: UUID,
school_update: schemas_core.CoreSchoolUpdate,
):
await db.execute(
update(models_core.CoreSchool)
.where(models_core.CoreSchool.id == school_id)
.values(**school_update.model_dump(exclude_none=True)),
)
await db.commit()
Loading

0 comments on commit a8a8097

Please sign in to comment.