Skip to content

Feature/notifications #331

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Feb 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e6f2e08
feat: create a notification page
IdanPelled Feb 8, 2021
474fc9f
move fixtures to a separate folfer
IdanPelled Feb 12, 2021
37ab0ba
Merge branch 'develop' into featare/notifications
IdanPelled Feb 13, 2021
4f2499a
Merge branch 'develop' into featare/notifications
IdanPelled Feb 14, 2021
5b3c613
Merge branch 'develop' into featare/notifications
IdanPelled Feb 14, 2021
e5a1602
add: notification icon
IdanPelled Feb 14, 2021
761ecae
add: tests for notifications
IdanPelled Feb 14, 2021
24c48d4
add: tests for notifications
IdanPelled Feb 14, 2021
fa51339
fix: testing bug
IdanPelled Feb 14, 2021
2582630
Merge branch 'develop' into featare/notifications
IdanPelled Feb 15, 2021
27d49c0
add: peer review changes
IdanPelled Feb 17, 2021
7a8751a
Merge branch 'develop' into featare/notifications
IdanPelled Feb 17, 2021
cbb459e
add: flake8 changes
IdanPelled Feb 17, 2021
d904a5c
add: historical notifications
IdanPelled Feb 19, 2021
2d868dd
Merge branch 'develop' into featare/notifications
IdanPelled Feb 20, 2021
559a339
add: current user suport
IdanPelled Feb 21, 2021
f0a468b
Merge branch 'develop' into featare/notifications
IdanPelled Feb 21, 2021
c0c1756
use root colors
IdanPelled Feb 21, 2021
b263d90
feat: improve tests
IdanPelled Feb 22, 2021
f701093
feat: split template into files
IdanPelled Feb 22, 2021
0df810b
feat: add exception handling
IdanPelled Feb 22, 2021
5201479
Merge branch 'develop' into featare/notifications
IdanPelled Feb 22, 2021
002eac2
add: peer review changes
IdanPelled Feb 23, 2021
3f79769
Merge branch 'develop' feature/notifications
IdanPelled Feb 24, 2021
d531d14
fix: import error
IdanPelled Feb 24, 2021
81a268e
add: peer review changes
IdanPelled Feb 24, 2021
8589755
fix: docstring
IdanPelled Feb 24, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
* PureDreamer - Developer
* ShiZinDle - Developer
* YairEn - Developer
* IdanPelled - Developer

# Special thanks to

Expand Down
71 changes: 66 additions & 5 deletions app/database/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from datetime import datetime
import enum
from typing import Any, Dict

from sqlalchemy import (
Boolean,
Column,
DateTime,
DDL,
Enum,
event,
Float,
ForeignKey,
Expand Down Expand Up @@ -203,20 +204,80 @@ class PSQLEnvironmentError(Exception):
)


class InvitationStatusEnum(enum.Enum):
UNREAD = 0
ACCEPTED = 1
DECLINED = 2


class MessageStatusEnum(enum.Enum):
UNREAD = 0
READ = 1


class Invitation(Base):
__tablename__ = "invitations"

id = Column(Integer, primary_key=True, index=True)
status = Column(String, nullable=False, default="unread")
creation = Column(DateTime, default=datetime.now, nullable=False)
status = Column(
Enum(InvitationStatusEnum),
default=InvitationStatusEnum.UNREAD,
nullable=False,
)

recipient_id = Column(Integer, ForeignKey("users.id"))
event_id = Column(Integer, ForeignKey("events.id"))
creation = Column(DateTime, default=datetime.now)

recipient = relationship("User")
event = relationship("Event")

def decline(self, session: Session) -> None:
"""declines the invitation."""
self.status = InvitationStatusEnum.DECLINED
session.merge(self)
session.commit()

def accept(self, session: Session) -> None:
"""Accepts the invitation by creating an
UserEvent association that represents
participantship at the event."""

association = UserEvent(
user_id=self.recipient.id,
event_id=self.event.id,
)
self.status = InvitationStatusEnum.ACCEPTED
session.merge(self)
session.add(association)
session.commit()

def __repr__(self):
return f"<Invitation ({self.event.owner} to {self.recipient})>"


class Message(Base):
__tablename__ = "messages"

id = Column(Integer, primary_key=True, index=True)
body = Column(String, nullable=False)
link = Column(String)
creation = Column(DateTime, default=datetime.now, nullable=False)
status = Column(
Enum(MessageStatusEnum),
default=MessageStatusEnum.UNREAD,
nullable=False,
)

recipient_id = Column(Integer, ForeignKey("users.id"))
recipient = relationship("User")

def mark_as_read(self, session):
self.status = MessageStatusEnum.READ
session.merge(self)
session.commit()

def __repr__(self):
return f"<Invitation " f"({self.event.owner}" f"to {self.recipient})>"
return f"<Message {self.id}>"


class UserSettings(Base):
Expand Down
50 changes: 32 additions & 18 deletions app/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pydantic import BaseModel, validator, EmailStr, EmailError


EMPTY_FIELD_STRING = 'field is required'
EMPTY_FIELD_STRING = "field is required"
MIN_FIELD_LENGTH = 3
MAX_FIELD_LENGTH = 20

Expand All @@ -19,59 +19,72 @@ class UserBase(BaseModel):
Validating fields types
Returns a User object without sensitive information
"""

username: str
email: str
full_name: str

language_id: Optional[int] = 1
description: Optional[str] = None
target_weight: Optional[Union[int, float]] = None

class Config:
orm_mode = True


class UserCreate(UserBase):
"""Validating fields types"""

password: str
confirm_password: str

"""
Calling to field_not_empty validaion function,
for each required field.
"""
_fields_not_empty_username = validator(
'username', allow_reuse=True)(fields_not_empty)
_fields_not_empty_full_name = validator(
'full_name', allow_reuse=True)(fields_not_empty)
_fields_not_empty_password = validator(
'password', allow_reuse=True)(fields_not_empty)
_fields_not_empty_username = validator("username", allow_reuse=True)(
fields_not_empty,
)
_fields_not_empty_full_name = validator("full_name", allow_reuse=True)(
fields_not_empty,
)
_fields_not_empty_password = validator("password", allow_reuse=True)(
fields_not_empty,
)
_fields_not_empty_confirm_password = validator(
'confirm_password', allow_reuse=True)(fields_not_empty)
_fields_not_empty_email = validator(
'email', allow_reuse=True)(fields_not_empty)

@validator('confirm_password')
"confirm_password",
allow_reuse=True,
)(fields_not_empty)
_fields_not_empty_email = validator("email", allow_reuse=True)(
fields_not_empty,
)

@validator("confirm_password")
def passwords_match(
cls, confirm_password: str,
values: UserBase) -> Union[ValueError, str]:
cls,
confirm_password: str,
values: UserBase,
) -> Union[ValueError, str]:
"""Validating passwords fields identical."""
if 'password' in values and confirm_password != values['password']:
if "password" in values and confirm_password != values["password"]:
raise ValueError("doesn't match to password")
return confirm_password

@validator('username')
@validator("username")
def username_length(cls, username: str) -> Union[ValueError, str]:
"""Validating username length is legal"""
if not (MIN_FIELD_LENGTH < len(username) < MAX_FIELD_LENGTH):
raise ValueError("must contain between 3 to 20 charactars")
return username

@validator('password')
@validator("password")
def password_length(cls, password: str) -> Union[ValueError, str]:
"""Validating username length is legal"""
if not (MIN_FIELD_LENGTH < len(password) < MAX_FIELD_LENGTH):
raise ValueError("must contain between 3 to 20 charactars")
return password

@validator('email')
@validator("email")
def confirm_mail(cls, email: str) -> Union[ValueError, str]:
"""Validating email is valid mail address."""
try:
Expand All @@ -86,5 +99,6 @@ class User(UserBase):
Validating fields types
Returns a User object without sensitive information
"""

id: int
is_active: bool
176 changes: 176 additions & 0 deletions app/internal/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
from operator import attrgetter
from typing import Iterator, List, Union, Callable

from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_406_NOT_ACCEPTABLE

from app.database.models import (
Invitation,
Message,
InvitationStatusEnum,
MessageStatusEnum,
)
from app.internal.utils import create_model


WRONG_NOTIFICATION_ID = (
"The notification id you have entered is wrong\n."
"If you did not enter the notification id manually, report this exception."
)

NOTIFICATION_TYPE = Union[Invitation, Message]

UNREAD_STATUS = {
InvitationStatusEnum.UNREAD,
MessageStatusEnum.UNREAD,
}

ARCHIVED = {
InvitationStatusEnum.DECLINED,
MessageStatusEnum.READ,
}


async def get_message_by_id(
message_id: int,
session: Session,
) -> Union[Message, None]:
"""Returns an invitation by an id.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leave blank line after the summary line.
The closing """ should be in their own line.

See PEP 257 for further details.

if id does not exist, returns None.
"""
return session.query(Message).filter_by(id=message_id).first()


def _is_unread(notification: NOTIFICATION_TYPE) -> bool:
"""Returns True if notification is unread, False otherwise."""
return notification.status in UNREAD_STATUS


def _is_archived(notification: NOTIFICATION_TYPE) -> bool:
"""Returns True if notification should be
in archived page, False otherwise.
"""
return notification.status in ARCHIVED


def is_owner(user, notification: NOTIFICATION_TYPE) -> bool:
"""Checks if user is owner of the notification.

Args:
notification: a NOTIFICATION_TYPE object.
user: user schema object.

Returns:
True or raises HTTPException.
"""
if notification.recipient_id == user.user_id:
return True

msg = "The notification you are trying to access is not yours."
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail=msg,
)


def raise_wrong_id_error() -> None:
"""Raises HTTPException.

Returns:
None
"""
raise HTTPException(
status_code=HTTP_406_NOT_ACCEPTABLE,
detail=WRONG_NOTIFICATION_ID,
)


def filter_notifications(
session: Session,
user_id: int,
func: Callable[[NOTIFICATION_TYPE], bool],
) -> Iterator[NOTIFICATION_TYPE]:
"""Filters notifications by "func"."""
yield from filter(func, get_all_notifications(session, user_id))


def get_unread_notifications(
session: Session,
user_id: int,
) -> Iterator[NOTIFICATION_TYPE]:
"""Returns all unread notifications."""
yield from filter_notifications(session, user_id, _is_unread)


def get_archived_notifications(
session: Session,
user_id: int,
) -> List[NOTIFICATION_TYPE]:
"""Returns all archived notifications."""
yield from filter_notifications(session, user_id, _is_archived)


def get_all_notifications(
session: Session,
user_id: int,
) -> List[NOTIFICATION_TYPE]:
"""Returns all notifications."""
invitations: List[Invitation] = get_all_invitations(
session,
recipient_id=user_id,
)
messages: List[Message] = get_all_messages(session, user_id)

notifications = invitations + messages
return sort_notifications(notifications)


def sort_notifications(
notification: List[NOTIFICATION_TYPE],
) -> List[NOTIFICATION_TYPE]:
"""Sorts the notifications by the creation date."""
return sorted(notification, key=attrgetter("creation"), reverse=True)


def create_message(
session: Session,
msg: str,
recipient_id: int,
link=None,
) -> Message:
"""Creates a new message."""
return create_model(
session,
Message,
body=msg,
recipient_id=recipient_id,
link=link,
)


def get_all_messages(session: Session, recipient_id: int) -> List[Message]:
"""Returns all messages."""
condition = Message.recipient_id == recipient_id
return session.query(Message).filter(condition).all()


def get_all_invitations(session: Session, **param) -> List[Invitation]:
"""Returns all invitations filter by param."""
try:
invitations = session.query(Invitation).filter_by(**param).all()
except SQLAlchemyError:
return []
else:
return invitations


def get_invitation_by_id(
invitation_id: int,
session: Session,
) -> Union[Invitation, None]:
"""Returns an invitation by an id.
if id does not exist, returns None.
"""
return session.query(Invitation).filter_by(id=invitation_id).first()
Loading