Skip to content

Commit b4b0ede

Browse files
authored
Feature/notifications (#331)
1 parent b947751 commit b4b0ede

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1723
-828
lines changed

AUTHORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* PureDreamer - Developer
3434
* ShiZinDle - Developer
3535
* YairEn - Developer
36+
* IdanPelled - Developer
3637

3738
# Special thanks to
3839

app/database/models.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
2-
32
from datetime import datetime
3+
import enum
44
from typing import Any, Dict
55

66
from sqlalchemy import (
77
Boolean,
88
Column,
99
DateTime,
1010
DDL,
11+
Enum,
1112
event,
1213
Float,
1314
ForeignKey,
@@ -203,20 +204,80 @@ class PSQLEnvironmentError(Exception):
203204
)
204205

205206

207+
class InvitationStatusEnum(enum.Enum):
208+
UNREAD = 0
209+
ACCEPTED = 1
210+
DECLINED = 2
211+
212+
213+
class MessageStatusEnum(enum.Enum):
214+
UNREAD = 0
215+
READ = 1
216+
217+
206218
class Invitation(Base):
207219
__tablename__ = "invitations"
208220

209221
id = Column(Integer, primary_key=True, index=True)
210-
status = Column(String, nullable=False, default="unread")
222+
creation = Column(DateTime, default=datetime.now, nullable=False)
223+
status = Column(
224+
Enum(InvitationStatusEnum),
225+
default=InvitationStatusEnum.UNREAD,
226+
nullable=False,
227+
)
228+
211229
recipient_id = Column(Integer, ForeignKey("users.id"))
212230
event_id = Column(Integer, ForeignKey("events.id"))
213-
creation = Column(DateTime, default=datetime.now)
214-
215231
recipient = relationship("User")
216232
event = relationship("Event")
217233

234+
def decline(self, session: Session) -> None:
235+
"""declines the invitation."""
236+
self.status = InvitationStatusEnum.DECLINED
237+
session.merge(self)
238+
session.commit()
239+
240+
def accept(self, session: Session) -> None:
241+
"""Accepts the invitation by creating an
242+
UserEvent association that represents
243+
participantship at the event."""
244+
245+
association = UserEvent(
246+
user_id=self.recipient.id,
247+
event_id=self.event.id,
248+
)
249+
self.status = InvitationStatusEnum.ACCEPTED
250+
session.merge(self)
251+
session.add(association)
252+
session.commit()
253+
254+
def __repr__(self):
255+
return f"<Invitation ({self.event.owner} to {self.recipient})>"
256+
257+
258+
class Message(Base):
259+
__tablename__ = "messages"
260+
261+
id = Column(Integer, primary_key=True, index=True)
262+
body = Column(String, nullable=False)
263+
link = Column(String)
264+
creation = Column(DateTime, default=datetime.now, nullable=False)
265+
status = Column(
266+
Enum(MessageStatusEnum),
267+
default=MessageStatusEnum.UNREAD,
268+
nullable=False,
269+
)
270+
271+
recipient_id = Column(Integer, ForeignKey("users.id"))
272+
recipient = relationship("User")
273+
274+
def mark_as_read(self, session):
275+
self.status = MessageStatusEnum.READ
276+
session.merge(self)
277+
session.commit()
278+
218279
def __repr__(self):
219-
return f"<Invitation " f"({self.event.owner}" f"to {self.recipient})>"
280+
return f"<Message {self.id}>"
220281

221282

222283
class UserSettings(Base):

app/database/schemas.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pydantic import BaseModel, validator, EmailStr, EmailError
33

44

5-
EMPTY_FIELD_STRING = 'field is required'
5+
EMPTY_FIELD_STRING = "field is required"
66
MIN_FIELD_LENGTH = 3
77
MAX_FIELD_LENGTH = 20
88

@@ -19,59 +19,72 @@ class UserBase(BaseModel):
1919
Validating fields types
2020
Returns a User object without sensitive information
2121
"""
22+
2223
username: str
2324
email: str
2425
full_name: str
26+
27+
language_id: Optional[int] = 1
2528
description: Optional[str] = None
29+
target_weight: Optional[Union[int, float]] = None
2630

2731
class Config:
2832
orm_mode = True
2933

3034

3135
class UserCreate(UserBase):
3236
"""Validating fields types"""
37+
3338
password: str
3439
confirm_password: str
3540

3641
"""
3742
Calling to field_not_empty validaion function,
3843
for each required field.
3944
"""
40-
_fields_not_empty_username = validator(
41-
'username', allow_reuse=True)(fields_not_empty)
42-
_fields_not_empty_full_name = validator(
43-
'full_name', allow_reuse=True)(fields_not_empty)
44-
_fields_not_empty_password = validator(
45-
'password', allow_reuse=True)(fields_not_empty)
45+
_fields_not_empty_username = validator("username", allow_reuse=True)(
46+
fields_not_empty,
47+
)
48+
_fields_not_empty_full_name = validator("full_name", allow_reuse=True)(
49+
fields_not_empty,
50+
)
51+
_fields_not_empty_password = validator("password", allow_reuse=True)(
52+
fields_not_empty,
53+
)
4654
_fields_not_empty_confirm_password = validator(
47-
'confirm_password', allow_reuse=True)(fields_not_empty)
48-
_fields_not_empty_email = validator(
49-
'email', allow_reuse=True)(fields_not_empty)
50-
51-
@validator('confirm_password')
55+
"confirm_password",
56+
allow_reuse=True,
57+
)(fields_not_empty)
58+
_fields_not_empty_email = validator("email", allow_reuse=True)(
59+
fields_not_empty,
60+
)
61+
62+
@validator("confirm_password")
5263
def passwords_match(
53-
cls, confirm_password: str,
54-
values: UserBase) -> Union[ValueError, str]:
64+
cls,
65+
confirm_password: str,
66+
values: UserBase,
67+
) -> Union[ValueError, str]:
5568
"""Validating passwords fields identical."""
56-
if 'password' in values and confirm_password != values['password']:
69+
if "password" in values and confirm_password != values["password"]:
5770
raise ValueError("doesn't match to password")
5871
return confirm_password
5972

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

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

74-
@validator('email')
87+
@validator("email")
7588
def confirm_mail(cls, email: str) -> Union[ValueError, str]:
7689
"""Validating email is valid mail address."""
7790
try:
@@ -86,5 +99,6 @@ class User(UserBase):
8699
Validating fields types
87100
Returns a User object without sensitive information
88101
"""
102+
89103
id: int
90104
is_active: bool

app/internal/notification.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
from operator import attrgetter
2+
from typing import Iterator, List, Union, Callable
3+
4+
from fastapi import HTTPException
5+
from sqlalchemy.exc import SQLAlchemyError
6+
from sqlalchemy.orm import Session
7+
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_406_NOT_ACCEPTABLE
8+
9+
from app.database.models import (
10+
Invitation,
11+
Message,
12+
InvitationStatusEnum,
13+
MessageStatusEnum,
14+
)
15+
from app.internal.utils import create_model
16+
17+
18+
WRONG_NOTIFICATION_ID = (
19+
"The notification id you have entered is wrong\n."
20+
"If you did not enter the notification id manually, report this exception."
21+
)
22+
23+
NOTIFICATION_TYPE = Union[Invitation, Message]
24+
25+
UNREAD_STATUS = {
26+
InvitationStatusEnum.UNREAD,
27+
MessageStatusEnum.UNREAD,
28+
}
29+
30+
ARCHIVED = {
31+
InvitationStatusEnum.DECLINED,
32+
MessageStatusEnum.READ,
33+
}
34+
35+
36+
async def get_message_by_id(
37+
message_id: int,
38+
session: Session,
39+
) -> Union[Message, None]:
40+
"""Returns an invitation by an id.
41+
if id does not exist, returns None.
42+
"""
43+
return session.query(Message).filter_by(id=message_id).first()
44+
45+
46+
def _is_unread(notification: NOTIFICATION_TYPE) -> bool:
47+
"""Returns True if notification is unread, False otherwise."""
48+
return notification.status in UNREAD_STATUS
49+
50+
51+
def _is_archived(notification: NOTIFICATION_TYPE) -> bool:
52+
"""Returns True if notification should be
53+
in archived page, False otherwise.
54+
"""
55+
return notification.status in ARCHIVED
56+
57+
58+
def is_owner(user, notification: NOTIFICATION_TYPE) -> bool:
59+
"""Checks if user is owner of the notification.
60+
61+
Args:
62+
notification: a NOTIFICATION_TYPE object.
63+
user: user schema object.
64+
65+
Returns:
66+
True or raises HTTPException.
67+
"""
68+
if notification.recipient_id == user.user_id:
69+
return True
70+
71+
msg = "The notification you are trying to access is not yours."
72+
raise HTTPException(
73+
status_code=HTTP_401_UNAUTHORIZED,
74+
detail=msg,
75+
)
76+
77+
78+
def raise_wrong_id_error() -> None:
79+
"""Raises HTTPException.
80+
81+
Returns:
82+
None
83+
"""
84+
raise HTTPException(
85+
status_code=HTTP_406_NOT_ACCEPTABLE,
86+
detail=WRONG_NOTIFICATION_ID,
87+
)
88+
89+
90+
def filter_notifications(
91+
session: Session,
92+
user_id: int,
93+
func: Callable[[NOTIFICATION_TYPE], bool],
94+
) -> Iterator[NOTIFICATION_TYPE]:
95+
"""Filters notifications by "func"."""
96+
yield from filter(func, get_all_notifications(session, user_id))
97+
98+
99+
def get_unread_notifications(
100+
session: Session,
101+
user_id: int,
102+
) -> Iterator[NOTIFICATION_TYPE]:
103+
"""Returns all unread notifications."""
104+
yield from filter_notifications(session, user_id, _is_unread)
105+
106+
107+
def get_archived_notifications(
108+
session: Session,
109+
user_id: int,
110+
) -> List[NOTIFICATION_TYPE]:
111+
"""Returns all archived notifications."""
112+
yield from filter_notifications(session, user_id, _is_archived)
113+
114+
115+
def get_all_notifications(
116+
session: Session,
117+
user_id: int,
118+
) -> List[NOTIFICATION_TYPE]:
119+
"""Returns all notifications."""
120+
invitations: List[Invitation] = get_all_invitations(
121+
session,
122+
recipient_id=user_id,
123+
)
124+
messages: List[Message] = get_all_messages(session, user_id)
125+
126+
notifications = invitations + messages
127+
return sort_notifications(notifications)
128+
129+
130+
def sort_notifications(
131+
notification: List[NOTIFICATION_TYPE],
132+
) -> List[NOTIFICATION_TYPE]:
133+
"""Sorts the notifications by the creation date."""
134+
return sorted(notification, key=attrgetter("creation"), reverse=True)
135+
136+
137+
def create_message(
138+
session: Session,
139+
msg: str,
140+
recipient_id: int,
141+
link=None,
142+
) -> Message:
143+
"""Creates a new message."""
144+
return create_model(
145+
session,
146+
Message,
147+
body=msg,
148+
recipient_id=recipient_id,
149+
link=link,
150+
)
151+
152+
153+
def get_all_messages(session: Session, recipient_id: int) -> List[Message]:
154+
"""Returns all messages."""
155+
condition = Message.recipient_id == recipient_id
156+
return session.query(Message).filter(condition).all()
157+
158+
159+
def get_all_invitations(session: Session, **param) -> List[Invitation]:
160+
"""Returns all invitations filter by param."""
161+
try:
162+
invitations = session.query(Invitation).filter_by(**param).all()
163+
except SQLAlchemyError:
164+
return []
165+
else:
166+
return invitations
167+
168+
169+
def get_invitation_by_id(
170+
invitation_id: int,
171+
session: Session,
172+
) -> Union[Invitation, None]:
173+
"""Returns an invitation by an id.
174+
if id does not exist, returns None.
175+
"""
176+
return session.query(Invitation).filter_by(id=invitation_id).first()

0 commit comments

Comments
 (0)