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 %} +
+
+

Favorite Quotes

+ {% for quote in quotes %} +

{{ quote.text }} + {% if quote.author %} +   \ {{ quote.author }} + {% else %} +   + {% endif %} + + +

+ {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html deleted file mode 100644 index f7d8d0d2..00000000 --- a/app/templates/home.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "base.html" %} - -{% block content %} - - -
-
- -
-
-
- Start Audio - Stop Audio -
- - -
- {% if quote %} - {% if not quote.author %} -

"{{ quote.text }}"

- {% else %} -

"{{ quote.text }}"   \ {{ quote.author }}

- {% endif %} - {% endif %} -
- - -
- {% if quote %} - {% if not quote.author%} -

"{{ quote.text }}"

- {% else %} -

"{{ quote.text }}"   \ {{quote.author}}

- {% endif %} - {% endif %} -
- -{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index bcd269d8..c564fd5e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -12,18 +12,23 @@

Open Source Calendar built with Python

-
+
- - {% if quote %} - {% if not quote.author%} -

"{{ quote.text }}"

+ {% if quote %} +

{{ quote.text }} + {% if quote.author %} +   \ {{ quote.author }} {% else %} -

"{{ quote.text }}"   \ {{quote.author}} -

+   {% endif %} + {% if quote.is_favorite %} + + {% else %} + {% endif %} + +

+ {% endif %}
@@ -47,4 +52,4 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/partials/user_profile/sidebar_left/features_card.html b/app/templates/partials/user_profile/sidebar_left/features_card.html index 946d1040..7abab9c6 100644 --- a/app/templates/partials/user_profile/sidebar_left/features_card.html +++ b/app/templates/partials/user_profile/sidebar_left/features_card.html @@ -35,6 +35,11 @@ {% include 'partials/user_profile/sidebar_left/features_card/export_calendar.html' %} +
  • + Favorite + Quotes +
  • + diff --git a/tests/fixtures/client_fixture.py b/tests/fixtures/client_fixture.py index eb96ef68..4542ce2e 100644 --- a/tests/fixtures/client_fixture.py +++ b/tests/fixtures/client_fixture.py @@ -110,6 +110,11 @@ def profile_test_client() -> Generator[Session, None, None]: Base.metadata.drop_all(bind=test_engine) +@pytest.fixture(scope="session") +def quotes_test_client() -> Iterator[TestClient]: + yield from create_test_client(main.get_db) + + @pytest.fixture(scope="session") def audio_test_client() -> Iterator[TestClient]: yield from create_test_client(audio.get_db) diff --git a/tests/fixtures/quotes_fixture.py b/tests/fixtures/quotes_fixture.py index 3f9d4e80..ef7c0bbd 100644 --- a/tests/fixtures/quotes_fixture.py +++ b/tests/fixtures/quotes_fixture.py @@ -6,7 +6,10 @@ def add_quote( - session: Session, id_quote: int, text: str, author: str + session: Session, + id_quote: int, + text: str, + author: str, ) -> Quote: quote = create_model( session, @@ -24,8 +27,8 @@ def quote1(session: Session) -> Quote: yield from add_quote( session=session, id_quote=1, - text='You have to believe in yourself.', - author='Sun Tzu', + text="You have to believe in yourself.", + author="Sun Tzu", ) @@ -34,6 +37,6 @@ def quote2(session: Session) -> Quote: yield from add_quote( session=session, id_quote=2, - text='Wisdom begins in wonder.', - author='Socrates', + text="Wisdom begins in wonder.", + author="Socrates", ) diff --git a/tests/meds/test_routers.py b/tests/meds/test_routers.py index d788c8ca..81773c65 100644 --- a/tests/meds/test_routers.py +++ b/tests/meds/test_routers.py @@ -33,7 +33,7 @@ def test_meds_send_form_success( assert response.ok message = "PyLendar" in response.text assert message is pylendar - message = 'alert-danger' in response.text + message = "alert-danger" in response.text assert message is not pylendar event = session.query(Event).first() if pylendar: diff --git a/tests/test_audio.py b/tests/test_audio.py index f7040b9b..f6928f6d 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,28 +1,42 @@ from app.routers.audio import router +from tests.test_login import test_login_successfull AUDIO_SETTINGS_URL = router.url_path_for("audio_settings") GET_CHOICES_URL = router.url_path_for("get_choices") START_AUDIO_URL = router.url_path_for("start_audio") -def test_get_settings(audio_test_client): +def test_get_settings(session, audio_test_client): + test_login_successfull(session, audio_test_client) response = audio_test_client.get(url=AUDIO_SETTINGS_URL) assert response.ok assert b"Audio Settings" in response.content + assert b"GASTRONOMICA.mp3" in response.content + assert b"SQUEEK!.mp3" in response.content + assert b"PHARMACOKINETICS.mp3" in response.content + assert b"click_1.wav" in response.content + assert b"click_2.wav" in response.content + assert b"click_3.wav" in response.content + assert b"click_4.wav" in response.content -def test_start_audio_default(audio_test_client): +def test_start_audio_default(session, audio_test_client): + test_login_successfull(session, audio_test_client) response = audio_test_client.get(START_AUDIO_URL) assert response.ok -def test_choices_Off(audio_test_client): +def test_choices_Off(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) data = {"music_on": False, "sfx_on": False} response = audio_test_client.post(url=GET_CHOICES_URL, data=data) assert response.ok -def test_choices_On(audio_test_client): +def test_choices_On(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) data = { "music_on": True, "music_choices": ["GASTRONOMICA.mp3"], @@ -35,7 +49,51 @@ def test_choices_On(audio_test_client): assert response.ok -def test_start_audio(audio_test_client): +def test_changing_choices(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) + test_choices_On(session, audio_test_client) + data = { + "music_on": True, + "music_choices": ["SQUEEK!.mp3"], + "music_vol": 15, + "sfx_on": True, + "sfx_choice": "click_2.wav", + "sfx_vol": 100, + } + response = audio_test_client.post(url=GET_CHOICES_URL, data=data) + assert response.ok + + +def test_just_music_on(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) + data = { + "music_on": True, + "music_choices": ["PHARMACOKINETICS.mp3"], + "music_vol": 75, + "sfx_on": False, + } + response = audio_test_client.post(url=GET_CHOICES_URL, data=data) + assert response.ok + + +def test_just_sfx_on(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) + data = { + "music_on": False, + "sfx_on": True, + "sfx_choice": "click_3.wav", + "sfx_vol": 20, + } + response = audio_test_client.post(url=GET_CHOICES_URL, data=data) + assert response.ok + + +def test_start_audio(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) data = { "music_on": True, "music_choices": ["GASTRONOMICA.mp3"], @@ -49,7 +107,9 @@ def test_start_audio(audio_test_client): assert response.ok -def test_start_audio_sfx_off(audio_test_client): +def test_start_audio_sfx_off(session, audio_test_client): + test_get_settings(session, audio_test_client) + test_login_successfull(session, audio_test_client) data = {"music_on_off": "Off", "sfx_on_off": "Off"} audio_test_client.post(url=GET_CHOICES_URL, data=data) response = audio_test_client.get(url=START_AUDIO_URL) diff --git a/tests/test_feature_panel.py b/tests/test_feature_panel.py index 1ee57b7c..21d697d4 100644 --- a/tests/test_feature_panel.py +++ b/tests/test_feature_panel.py @@ -1,8 +1,8 @@ import pytest -from app.database.models import Feature, UserFeature import app.internal.features as internal import app.routers.features as route +from app.database.models import Feature, UserFeature from tests.test_login import LOGIN_DATA, REGISTER_DETAIL @@ -128,10 +128,10 @@ def test_delete_feature(session, feature): def test_is_feature_exist_in_db(session, feature): - assert internal.is_feature_exists({ - 'name': 'test', - 'route': '/test' - }, session) + assert internal.is_feature_exists( + {"name": "test", "route": "/test"}, + session, + ) def test_update_feature(session, feature, update_dict): @@ -167,14 +167,14 @@ def test_create_feature(session): assert feat.name == "test1" -def test_index(security_test_client): +def test_index(session, security_test_client): url = route.router.url_path_for("index") resp = security_test_client.get(url) assert resp.ok -def test_add_feature_to_user(form_mock, security_test_client): +def test_add_feature_to_user(session, form_mock, security_test_client): url = route.router.url_path_for("add_feature_to_user") security_test_client.post( diff --git a/tests/test_quotes.py b/tests/test_quotes.py index 304676a0..8e21d7af 100644 --- a/tests/test_quotes.py +++ b/tests/test_quotes.py @@ -1,19 +1,27 @@ from datetime import date from app.internal import daily_quotes +from app.internal.security.dependencies import current_user +from app.main import app +from app.routers.favorite_quotes import router +from tests.test_login import test_login_successfull DATE = date(2021, 1, 1) DATE2 = date(2021, 1, 2) +HOME_URL = app.url_path_for("home") +SAVE_URL = router.url_path_for("save_quote") +DELETE_URL = router.url_path_for("delete_quote") +FAVORITE_QUOTES_URL = router.url_path_for("favorite_quotes") def test_get_quote(): quotes_fields = { - 'text': 'some_quote', - 'author': 'Freud', + "text": "some_quote", + "author": "Freud", } result = daily_quotes.get_quote(quotes_fields) - assert result.text == 'some_quote' - assert result.author == 'Freud' + assert result.text == "some_quote" + assert result.author == "Freud" def test_get_quote_of_day_no_quotes(session): @@ -21,10 +29,52 @@ def test_get_quote_of_day_no_quotes(session): def test_get_quote_of_day_get_first_quote(session, quote1, quote2): - assert daily_quotes.get_quote_of_day( - session, DATE).text == quote1.text + assert daily_quotes.get_quote_of_day(session, DATE).text == quote1.text def test_get_quote_of_day_get_second_quote(session, quote1, quote2): - assert daily_quotes.get_quote_of_day( - session, DATE2).text == quote2.text + assert daily_quotes.get_quote_of_day(session, DATE2).text == quote2.text + + +def test_save_quote(session, quotes_test_client, quote1): + test_login_successfull(session, quotes_test_client) + data = { + "quote_id": quote1.id, + "author": quote1.author, + "to_save": True, + "user": current_user, + } + quotes = daily_quotes.get_quotes(session, 1) + response = quotes_test_client.post(url=SAVE_URL, data=data) + assert response.ok + assert len(daily_quotes.get_quotes(session, 1)) == len(quotes) + 1 + + +def test_delete_quote(session, quotes_test_client, quote1): + test_save_quote(session, quotes_test_client, quote1) + data = { + "quote_id": quote1.id, + "to_save": False, + } + response = quotes_test_client.delete(url=DELETE_URL, data=data) + assert response.ok + assert len(daily_quotes.get_quotes(session, 1)) == 0 + + +def test_quote_of_day(session, quotes_test_client, quote1): + assert not daily_quotes.is_quote_favorite(session, 1, quote1) + test_save_quote(session, quotes_test_client, quote1) + assert daily_quotes.is_quote_favorite(session, 1, quote1) + + +def test_get_favorite_quotes(session, quotes_test_client, quote1): + test_save_quote(session, quotes_test_client, quote1) + response = quotes_test_client.get(url=FAVORITE_QUOTES_URL) + assert response.ok + assert b"Favorite Quotes" in response.content + + +def test_home(session, quotes_test_client, quote1): + response = quotes_test_client.get(url=HOME_URL) + assert response.ok + assert b"Search" in response.content