diff --git a/app/database/models.py b/app/database/models.py index 4466dc76..8ea73b66 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -39,8 +39,8 @@ class UserFeature(Base): __tablename__ = "user_feature" id = Column(Integer, primary_key=True, index=True) - feature_id = Column('feature_id', Integer, ForeignKey('features.id')) - user_id = Column('user_id', Integer, ForeignKey('users.id')) + feature_id = Column("feature_id", Integer, ForeignKey("features.id")) + user_id = Column("user_id", Integer, ForeignKey("users.id")) is_enable = Column(Boolean, default=False) @@ -488,6 +488,7 @@ class Quote(Base): id = Column(Integer, primary_key=True, index=True) text = Column(String, nullable=False) author = Column(String) + is_favorite = Column(Boolean, default=False, nullable=False) class Country(Base): @@ -614,3 +615,11 @@ def insert_data(target, session: Session, **kw): event.listen(Language.__table__, "after_create", insert_data) + + +class UserQuotes(Base): + __tablename__ = "user_quotes" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + quote_id = Column(Integer, ForeignKey("quotes.id"), nullable=False) diff --git a/app/internal/daily_quotes.py b/app/internal/daily_quotes.py index b07a98fb..ec636eab 100644 --- a/app/internal/daily_quotes.py +++ b/app/internal/daily_quotes.py @@ -1,10 +1,10 @@ from datetime import date -from typing import Dict, Optional +from typing import Dict, List, Optional from sqlalchemy.orm import Session from sqlalchemy.sql.expression import func -from app.database.models import Quote +from app.database.models import Quote, UserQuotes TOTAL_DAYS = 366 @@ -19,13 +19,15 @@ def get_quote(quote_: Dict[str, Optional[str]]) -> Quote: A new Quote object. """ return Quote( - text=quote_['text'], - author=quote_['author'], + text=quote_["text"], + author=quote_["author"], + is_favorite=False, ) def get_quote_of_day( - session: Session, requested_date: date = date.today() + session: Session, + requested_date: date = date.today(), ) -> Optional[Quote]: """Returns the Quote object for the specific day. @@ -39,9 +41,32 @@ def get_quote_of_day( A Quote object. """ day_number = requested_date.timetuple().tm_yday - quote = (session.query(Quote) - .filter(Quote.id % TOTAL_DAYS == day_number) - .order_by(func.random()) - .first() - ) + quote = ( + session.query(Quote) + .filter(Quote.id % TOTAL_DAYS == day_number) + .order_by(func.random()) + .first() + ) return quote + + +def get_quotes(session: Session, user_id: int) -> List[Quote]: + """Retrieves the users' favorite quotes from the database.""" + return session.query(Quote).filter_by(id=UserQuotes.quote_id).all() + + +def is_quote_favorite( + session: Session, + user_id: int, + quote_of_day: Quote, +) -> bool: + """Checks if the daily quote is in favorites list.""" + if not quote_of_day: + return False + + exists = ( + session.query(UserQuotes) + .filter(user_id == user_id, UserQuotes.quote_id == quote_of_day.id) + .scalar() + ) + return bool(exists) diff --git a/app/internal/event.py b/app/internal/event.py index 3d4bebfe..d6beeaf3 100644 --- a/app/internal/event.py +++ b/app/internal/event.py @@ -174,4 +174,4 @@ async def get_location_coordinates( name=geolocation.raw["display_name"], ) return location - return address + return address # delete db test to make sure diff --git a/app/main.py b/app/main.py index 0170198e..1257bca6 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session +import app.internal.features as internal_features from app import config from app.database import engine, models from app.dependencies import ( @@ -13,14 +14,14 @@ SOUNDS_PATH, STATIC_PATH, UPLOAD_PATH, + SessionLocal, get_db, logger, templates, - SessionLocal, ) from app.internal import daily_quotes, json_data_loader -import app.internal.features as internal_features from app.internal.languages import set_ui_language +from app.internal.security.dependencies import current_user from app.internal.security.ouath2 import auth_exception_handler from app.routers.salary import routes as salary from app.utils.extending_openapi import custom_openapi @@ -68,6 +69,7 @@ def create_tables(engine, psql_environment): email, event, export, + favorite_quotes, features, four_o_four, friendview, @@ -122,6 +124,7 @@ async def swagger_ui_redirect(): email.router, event.router, export.router, + favorite_quotes.router, features.router, four_o_four.router, friendview.router, @@ -156,17 +159,29 @@ async def startup_event(): session.close() -# TODO: I add the quote day to the home page -# until the relevant calendar view will be developed. @app.get("/", include_in_schema=False) @logger.catch() -async def home(request: Request, db: Session = Depends(get_db)): - quote = daily_quotes.get_quote_of_day(db) +async def home( + request: Request, + db: Session = Depends(get_db), +) -> templates.TemplateResponse: + """Home page for the website.""" + user_id = False + if "Authorization" in request.cookies: + jwt = request.cookies["Authorization"] + user = await current_user(request=request, db=db, jwt=jwt) + user_id = user.user_id + is_connected = bool(user_id) + quote_of_day = daily_quotes.get_quote_of_day(db) + quote = daily_quotes.is_quote_favorite(db, user_id, quote_of_day) + if is_connected and quote: + quote_of_day.is_favorite = True return templates.TemplateResponse( "index.html", { "request": request, - "quote": quote, + "is_connected": is_connected, + "quote": quote_of_day, }, ) diff --git a/app/media/empty_heart.png b/app/media/empty_heart.png new file mode 100644 index 00000000..d725092a Binary files /dev/null and b/app/media/empty_heart.png differ diff --git a/app/media/full_heart.png b/app/media/full_heart.png new file mode 100644 index 00000000..d9af5d57 Binary files /dev/null and b/app/media/full_heart.png differ diff --git a/app/routers/favorite_quotes.py b/app/routers/favorite_quotes.py new file mode 100644 index 00000000..e81ca128 --- /dev/null +++ b/app/routers/favorite_quotes.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm import Session + +from app.database import models +from app.dependencies import get_db, templates +from app.internal import daily_quotes +from app.internal.security.dependencies import current_user + +router = APIRouter( + prefix="/quotes", + tags=["quotes"], + responses={404: {"description": "Not found"}}, +) + + +@router.post("/save") +async def save_quote( + user: models.User = Depends(current_user), + quote_id: int = Form(...), + db: Session = Depends(get_db), +) -> None: + """Saves a quote in the database.""" + db.merge(models.UserQuotes(user_id=user.user_id, quote_id=quote_id)) + db.commit() + + +@router.delete("/delete") +async def delete_quote( + user: models.User = Depends(current_user), + quote_id: int = Form(...), + db: Session = Depends(get_db), +) -> None: + """Deletes a quote from the database.""" + db.query(models.UserQuotes).filter( + models.UserQuotes.user_id == user.user_id, + models.UserQuotes.quote_id == quote_id, + ).delete() + db.commit() + + +@router.get("/favorites") +async def favorite_quotes( + request: Request, + db: Session = Depends(get_db), + user: models.User = Depends(current_user), +) -> templates.TemplateResponse: + """html page for displaying the users' favorite quotes.""" + quotes = daily_quotes.get_quotes(db, user.user_id) + return templates.TemplateResponse( + "favorite_quotes.html", + { + "request": request, + "quotes": quotes, + }, + ) diff --git a/app/static/favorite_quotes.js b/app/static/favorite_quotes.js new file mode 100644 index 00000000..2c03472a --- /dev/null +++ b/app/static/favorite_quotes.js @@ -0,0 +1,68 @@ +const FULL_HEART = "../../media/full_heart.png"; +const EMPTY_HEART = "../../media/empty_heart.png"; + +// Adding event listener +window.addEventListener("load", function () { + const quoteContainer = document.getElementById("quote-container"); + if (!quoteContainer) { + return; + } + const isConnected = quoteContainer.dataset.connected; + if (isConnected !== "True") { + return; + } + const fullHeart = document.getElementsByClassName("full-heart")[0]; + const emptyHeart = document.getElementsByClassName("empty-heart")[0]; + if (fullHeart) { + fullHeart.classList.toggle("full-heart"); + } else if (emptyHeart) { + emptyHeart.classList.toggle("empty-heart"); + } + + let hearts = Array.from(document.getElementsByClassName("heart")); + hearts.forEach((heart_element) => { + if (heart_element) { + heart_element.addEventListener("click", function () { + onHeartClick(heart_element); + }); + } + }); +}); + +/** + * @summary This function is a handler for the event of heart-click. + * Whenever a user clicks on a heart icon, in case of empty heart: + * saves quote in favorites, as well as changing + * the heart icon from empty to full. + * In case of full heart: + * Removes it and switch back to empty heart icon. + * Uses the save_or_remove_quote function to handle db operations. + */ +function onHeartClick(heart_element) { + const quote_id = heart_element.dataset.qid; + if (heart_element.dataset.heart == "off") { + heart_element.src = FULL_HEART; + heart_element.dataset.heart = "on"; + save_or_remove_quote(quote_id, true); + } else { + heart_element.src = EMPTY_HEART; + heart_element.dataset.heart = "off"; + save_or_remove_quote(quote_id, false); + if (heart_element.classList.contains("favorites")) { + heart_element.parentNode.parentNode.remove(); + } + } +} + +/** + * @summary Saves or removes a quote from favorites. + */ +function save_or_remove_quote(quote_id, to_save) { + const method = to_save ? "post" : "delete"; + const url = method == "post" ? "/quotes/save" : "/quotes/delete"; + const xhr = new XMLHttpRequest(); + quote_id = parseInt(quote_id); + xhr.open(method, url); + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + xhr.send(`quote_id=${quote_id}`); +} diff --git a/app/static/style.css b/app/static/style.css index 53ff19bd..4af4d699 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -49,6 +49,15 @@ p { justify-content: space-around; } +p#p-quote { + align-items: center; + justify-content: center; +} + +#author { + font-size: small; +} + #inner:hover { cursor: pointer; padding: 50px; @@ -156,6 +165,27 @@ p { font-size: 1.25rem; } +.subtitle { + font-size: 1.25rem; +} + +.heart { + width: 1.5em; + height: 1.5em; +} + +.full-heart { + display: none; +} + +.empty-heart { + display: none; +} + +.favorite-quotes { + margin-bottom: 1.5em; +} + .error-message { line-height: 0; color: red; diff --git a/app/templates/base.html b/app/templates/base.html index 9bf7748d..0aefe4aa 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -94,6 +94,7 @@ + diff --git a/app/templates/favorite_quotes.html b/app/templates/favorite_quotes.html new file mode 100644 index 00000000..fe5d795e --- /dev/null +++ b/app/templates/favorite_quotes.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block content %} +
{{ quote.text }}
+ {% if quote.author %}
+
"{{ quote.text }}"
- {% else %} -"{{ quote.text }}" \ {{ quote.author }}
- {% endif %} - {% endif %} -"{{ quote.text }}"
- {% else %} -"{{ quote.text }}" \ {{quote.author}}
- {% endif %} - {% endif %} -Open Source Calendar built with Python
-"{{ quote.text }}"
+ {% if quote %} +{{ quote.text }}
+ {% if quote.author %}
+