diff --git a/.gitignore b/.gitignore index fedb2e46..0f45edc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ dev.db test.db +.idea config.py # Byte-compiled / optimized / DLL files @@ -146,6 +147,7 @@ dmypy.json .pyre/ # mac env +.DS_Store bin # register stuff @@ -161,3 +163,7 @@ app/routers/stam .idea junit/ + +# .DS_Store +.DS_Store +DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 459e779a..a29abf81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,4 @@ repos: - # Flake8 to check style is OK - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 - hooks: - - id: flake8 - # yapf to fix many style mistakes - - repo: https://github.com/ambv/black - rev: 20.8b1 - hooks: - - id: black - entry: black - language: python - language_version: python3 - require_serial: true - types_or: [python, pyi] # More built in style checks and fixes - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 @@ -28,6 +13,28 @@ repos: - id: check-merge-conflict - id: end-of-file-fixer - id: sort-simple-yaml + - repo: https://github.com/pycqa/isort + rev: 5.7.0 + hooks: + - id: isort + name: isort (python) + args: ["--profile", "black", "--line-length", "79"] + - id: isort + name: isort (cython) + types: [cython] + - id: isort + name: isort (pyi) + types: [pyi] + # Black: to fix many style mistakes + - repo: https://github.com/ambv/black + rev: 20.8b1 + hooks: + - id: black + entry: black + language: python + language_version: python3 + require_serial: true + types_or: [python, pyi] - repo: meta hooks: - id: check-useless-excludes @@ -35,3 +42,8 @@ repos: rev: v2.1.0 hooks: - id: add-trailing-comma + # Flake8 to check style is OK + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 diff --git a/AUTHORS.md b/AUTHORS.md index add6ca4b..4a487968 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -33,6 +33,8 @@ * PureDreamer - Developer * ShiZinDle - Developer * YairEn - Developer + * LiranCaduri - Developer + * IdanPelled - Developer # Special thanks to diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..16e9fab7 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,86 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# script_location = alembic +script_location = app\\database\\alembic + +# template used to generate migration files +file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/config.py.example b/app/config.py.example index 4556dcca..d296d02e 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -7,7 +7,7 @@ from starlette.templating import Jinja2Templates class Settings(BaseSettings): - app_name: str = "PyLander" + app_name: str = "PyLendar" bot_api: str = "BOT_API" webhook_url: str = "WEBHOOK_URL" @@ -27,6 +27,10 @@ PSQL_ENVIRONMENT = False MEDIA_DIRECTORY = 'media' PICTURE_EXTENSION = '.png' AVATAR_SIZE = (120, 120) +# For security reasons, set the upload path to a local absolute path. +# Or for testing environment - just specify a folder name +# that will be created under /app/ +UPLOAD_DIRECTORY = 'event_images' # DEFAULT WEBSITE LANGUAGE @@ -63,6 +67,7 @@ email_conf = ConnectionConfig( JWT_KEY = "JWT_KEY_PLACEHOLDER" JWT_ALGORITHM = "HS256" JWT_MIN_EXP = 60 * 24 * 7 + templates = Jinja2Templates(directory=os.path.join("app", "templates")) # application name diff --git a/app/database/alembic/README b/app/database/alembic/README new file mode 100644 index 00000000..7239903d --- /dev/null +++ b/app/database/alembic/README @@ -0,0 +1,14 @@ +Generic single-database configuration. + +# following command will run all migration script and bring it to latest version +alembic upgrade head + +# If we like to incrementally upgrade and check for some errors +alembic upgrade +1 + +# To undo last migration +alembic downgrade -1 + +# To get more information +alembic current +alembic history - verbose \ No newline at end of file diff --git a/app/internal/week.py b/app/database/alembic/__init__.py similarity index 100% rename from app/internal/week.py rename to app/database/alembic/__init__.py diff --git a/app/database/alembic/env.py b/app/database/alembic/env.py new file mode 100644 index 00000000..d1f1431a --- /dev/null +++ b/app/database/alembic/env.py @@ -0,0 +1,81 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import create_engine + +from app import config as app_config +from app.database.models import Base + +SQLALCHEMY_DATABASE_URL = os.getenv( + "DATABASE_CONNECTION_STRING", + app_config.DEVELOPMENT_DATABASE_STRING, +) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name, disable_existing_loggers=False) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = SQLALCHEMY_DATABASE_URL + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = create_engine(SQLALCHEMY_DATABASE_URL) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/app/database/alembic/script.py.mako b/app/database/alembic/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/app/database/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/app/database/alembic/versions/91b42971b0df_.py b/app/database/alembic/versions/91b42971b0df_.py new file mode 100644 index 00000000..c380e48d --- /dev/null +++ b/app/database/alembic/versions/91b42971b0df_.py @@ -0,0 +1,165 @@ +"""empty message + +Revision ID: 91b42971b0df +Revises: +Create Date: 2021-02-06 16:15:07.861957 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = "91b42971b0df" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_categories_id", table_name="categories") + op.drop_table("categories") + op.drop_index("ix_invitations_id", table_name="invitations") + op.drop_table("invitations") + op.drop_index("ix_users_id", table_name="users") + op.drop_table("users") + op.drop_index("ix_quotes_id", table_name="quotes") + op.drop_table("quotes") + op.drop_index("ix_wikipedia_events_id", table_name="wikipedia_events") + op.drop_table("wikipedia_events") + op.drop_index("ix_zodiac-signs_id", table_name="zodiac-signs") + op.drop_table("zodiac-signs") + op.drop_index("ix_events_id", table_name="events") + op.drop_table("events") + op.drop_index("ix_user_event_id", table_name="user_event") + op.drop_table("user_event") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_event", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("user_id", sa.INTEGER(), nullable=True), + sa.Column("event_id", sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint( + ["event_id"], + ["events.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_user_event_id", "user_event", ["id"], unique=False) + op.create_table( + "events", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("title", sa.VARCHAR(), nullable=False), + sa.Column("start", sa.DATETIME(), nullable=False), + sa.Column("end", sa.DATETIME(), nullable=False), + sa.Column("content", sa.VARCHAR(), nullable=True), + sa.Column("location", sa.VARCHAR(), nullable=True), + sa.Column("color", sa.VARCHAR(), nullable=True), + sa.Column("owner_id", sa.INTEGER(), nullable=True), + sa.Column("category_id", sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_events_id", "events", ["id"], unique=False) + op.create_table( + "zodiac-signs", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("name", sa.VARCHAR(), nullable=False), + sa.Column("start_month", sa.INTEGER(), nullable=False), + sa.Column("start_day_in_month", sa.INTEGER(), nullable=False), + sa.Column("end_month", sa.INTEGER(), nullable=False), + sa.Column("end_day_in_month", sa.INTEGER(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_zodiac-signs_id", "zodiac-signs", ["id"], unique=False) + op.create_table( + "wikipedia_events", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("date_", sa.VARCHAR(), nullable=False), + sa.Column("wikipedia", sa.VARCHAR(), nullable=False), + sa.Column("events", sqlite.JSON(), nullable=True), + sa.Column("date_inserted", sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_wikipedia_events_id", + "wikipedia_events", + ["id"], + unique=False, + ) + op.create_table( + "quotes", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("text", sa.VARCHAR(), nullable=False), + sa.Column("author", sa.VARCHAR(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_quotes_id", "quotes", ["id"], unique=False) + op.create_table( + "users", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("username", sa.VARCHAR(), nullable=False), + sa.Column("email", sa.VARCHAR(), nullable=False), + sa.Column("password", sa.VARCHAR(), nullable=False), + sa.Column("full_name", sa.VARCHAR(), nullable=True), + sa.Column("language", sa.VARCHAR(), nullable=True), + sa.Column("description", sa.VARCHAR(), nullable=True), + sa.Column("avatar", sa.VARCHAR(), nullable=True), + sa.Column("telegram_id", sa.VARCHAR(), nullable=True), + sa.Column("is_active", sa.BOOLEAN(), nullable=True), + sa.CheckConstraint("is_active IN (0, 1)"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("telegram_id"), + sa.UniqueConstraint("username"), + ) + op.create_index("ix_users_id", "users", ["id"], unique=False) + op.create_table( + "invitations", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("status", sa.VARCHAR(), nullable=False), + sa.Column("recipient_id", sa.INTEGER(), nullable=True), + sa.Column("event_id", sa.INTEGER(), nullable=True), + sa.Column("creation", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["event_id"], + ["events.id"], + ), + sa.ForeignKeyConstraint( + ["recipient_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_invitations_id", "invitations", ["id"], unique=False) + op.create_table( + "categories", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("name", sa.VARCHAR(), nullable=False), + sa.Column("color", sa.VARCHAR(), nullable=False), + sa.Column("user_id", sa.INTEGER(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", "color"), + ) + op.create_index("ix_categories_id", "categories", ["id"], unique=False) + # ### end Alembic commands ### diff --git a/app/database/models.py b/app/database/models.py index 0e4f4aea..390896c4 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,36 +1,50 @@ from __future__ import annotations +import enum from datetime import datetime from typing import Any, Dict from sqlalchemy import ( + DDL, + JSON, Boolean, Column, + Date, DateTime, - DDL, - event, + Enum, Float, ForeignKey, Index, Integer, - JSON, String, Time, UniqueConstraint, + event, ) from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from sqlalchemy.ext.declarative.api import declarative_base, DeclarativeMeta -from sqlalchemy.orm import relationship, Session +from sqlalchemy.ext.declarative.api import DeclarativeMeta, declarative_base +from sqlalchemy.orm import Session, relationship from sqlalchemy.sql.schema import CheckConstraint +import app.routers.salary.config as SalaryConfig from app.config import PSQL_ENVIRONMENT from app.dependencies import logger -import app.routers.salary.config as SalaryConfig +from app.internal.privacy import PrivacyKinds Base: DeclarativeMeta = declarative_base() +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")) + + is_enable = Column(Boolean, default=False) + + class User(Base): __tablename__ = "users" @@ -47,6 +61,7 @@ class User(Base): privacy = Column(String, default="Private", nullable=False) is_manager = Column(Boolean, default=False) language_id = Column(Integer, ForeignKey("languages.id")) + target_weight = Column(Float, nullable=True) owned_events = relationship( "Event", @@ -64,7 +79,10 @@ class User(Base): back_populates="user", ) comments = relationship("Comment", back_populates="user") + tasks = relationship( + "Task", cascade="all, delete", back_populates="owner") + features = relationship("Feature", secondary=UserFeature.__tablename__) oauth_credentials = relationship( "OAuthCredentials", cascade="all, delete", @@ -72,6 +90,8 @@ class User(Base): uselist=False, ) + notes = relationship("Note", back_populates="creator") + def __repr__(self): return f"" @@ -81,6 +101,18 @@ async def get_by_username(db: Session, username: str) -> User: return db.query(User).filter(User.username == username).first() +class Feature(Base): + __tablename__ = "features" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + route = Column(String, nullable=False) + creator = Column(String, nullable=True) + description = Column(String, nullable=False) + + users = relationship("User", secondary=UserFeature.__tablename__) + + class Event(Base): __tablename__ = "events" @@ -90,12 +122,16 @@ class Event(Base): end = Column(DateTime, nullable=False) content = Column(String) location = Column(String, nullable=True) + latitude = Column(String, nullable=True) + longitude = Column(String, nullable=True) + vc_link = Column(String, nullable=True) is_google_event = Column(Boolean, default=False) - vc_link = Column(String) color = Column(String, nullable=True) all_day = Column(Boolean, default=False) invitees = Column(String) + privacy = Column(String, default=PrivacyKinds.Public.name, nullable=False) emotion = Column(String, nullable=True) + image = Column(String, nullable=True) availability = Column(Boolean, default=True, nullable=False) owner_id = Column(Integer, ForeignKey("users.id")) @@ -107,6 +143,11 @@ class Event(Base): cascade="all, delete", back_populates="events", ) + shared_list = relationship( + "SharedList", + uselist=False, + back_populates="event", + ) comments = relationship("Comment", back_populates="event") # PostgreSQL @@ -198,20 +239,110 @@ 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"" + + +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"" + return f"" + + +class UserSettings(Base): + __tablename__ = "user_settings" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + music_on = Column(Boolean, default=False, nullable=False) + music_vol = Column(Integer, default=None) + sfx_on = Column(Boolean, default=False, nullable=False) + sfx_vol = Column(Integer, default=None) + primary_cursor = Column(String, default="default", nullable=False) + secondary_cursor = Column(String, default="default", nullable=False) + video_game_releases = Column(Boolean, default=False) + + +class AudioTracks(Base): + __tablename__ = "audio_tracks" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False, unique=True) + is_music = Column(Boolean, nullable=False) + + +class UserAudioTracks(Base): + __tablename__ = "user_audio_tracks" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + track_id = Column(Integer, ForeignKey("audio_tracks.id")) class OAuthCredentials(Base): @@ -339,6 +470,20 @@ class WikipediaEvents(Base): date_inserted = Column(DateTime, default=datetime.utcnow) +class CoronaStats(Base): + __tablename__ = "corona_stats" + + id = Column(Integer, primary_key=True, index=True) + date_ = Column(DateTime, nullable=False) + date_inserted = Column(DateTime, default=datetime.utcnow) + vaccinated = Column(Integer, nullable=False) + vaccinated_total = Column(Integer, nullable=False) + vaccinated_population_perc = Column(Integer, nullable=False) + vaccinated_second_dose = Column(Integer, nullable=False) + vaccinated_second_dose_total = Column(Integer, nullable=False) + vaccinated_second_dose_perc = Column(Float, nullable=False) + + class Quote(Base): __tablename__ = "quotes" @@ -347,6 +492,14 @@ class Quote(Base): author = Column(String) +class Country(Base): + __tablename__ = "countries" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False, unique=True) + timezone = Column(String, nullable=False) + + class Comment(Base): __tablename__ = "comments" @@ -382,11 +535,79 @@ def __repr__(self): ) +class SharedListItem(Base): + __tablename__ = "shared_list_item" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + amount = Column(Float, nullable=False) + participant = Column(String, nullable=True) + notes = Column(String, nullable=True) + shared_list_id = Column(Integer, ForeignKey("shared_list.id")) + + shared_list = relationship("SharedList", back_populates="items") + + def __repr__(self): + return ( + f"" + ) + + +class SharedList(Base): + __tablename__ = "shared_list" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(String, ForeignKey("events.id")) + title = Column(String, nullable=True) + + items = relationship("SharedListItem", back_populates="shared_list") + event = relationship("Event", back_populates="shared_list") + + def __repr__(self): + return f"" + + +class Joke(Base): + __tablename__ = "jokes" + + id = Column(Integer, primary_key=True, index=True) + text = Column(String, nullable=False) + + +class InternationalDays(Base): + __tablename__ = "international_days" + + id = Column(Integer, primary_key=True, index=True) + day = Column(Integer, nullable=False) + month = Column(Integer, nullable=False) + international_day = Column(String, nullable=False) + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(String, nullable=False) + is_done = Column(Boolean, default=False) + is_important = Column(Boolean, nullable=False) + date = Column(Date, nullable=False) + time = Column(Time, nullable=False) + owner_id = Column(Integer, ForeignKey("users.id")) + + owner = relationship("User", back_populates="tasks") + + # insert language data -# Credit to adrihanu https://stackoverflow.com/users/9127249/adrihanu -# https://stackoverflow.com/questions/17461251 + def insert_data(target, session: Session, **kw): + """insert language data + Credit to adrihanu https://stackoverflow.com/users/9127249/adrihanu + https://stackoverflow.com/questions/17461251""" session.execute( target.insert(), {"id": 1, "name": "English"}, @@ -395,3 +616,18 @@ def insert_data(target, session: Session, **kw): event.listen(Language.__table__, "after_create", insert_data) + + +class Note(Base): + __tablename__ = "notes" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(String) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) + user_id = Column(Integer, ForeignKey("users.id")) + + creator = relationship("User", back_populates="notes") + + def __repr__(self) -> str: + return f"" diff --git a/app/database/schemas.py b/app/database/schemas.py index 61d31a33..5b9ad02e 100644 --- a/app/database/schemas.py +++ b/app/database/schemas.py @@ -1,28 +1,41 @@ +from datetime import datetime from typing import Optional, Union -from pydantic import BaseModel, validator, EmailStr, EmailError +from pydantic import BaseModel, EmailError, EmailStr, Field, validator -EMPTY_FIELD_STRING = 'field is required' +EMPTY_FIELD_STRING = "field is required" MIN_FIELD_LENGTH = 3 MAX_FIELD_LENGTH = 20 -def fields_not_empty(field: Optional[str]) -> Union[ValueError, str]: +def fields_not_empty(field: Optional[str]) -> str: """Global function to validate fields are not empty.""" if not field: raise ValueError(EMPTY_FIELD_STRING) return field +class UserModel(BaseModel): + username: str + password: str + email: str = Field(regex="^\\S+@\\S+\\.\\S+$") + language: str + language_id: int + + 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 @@ -30,6 +43,7 @@ class Config: class UserCreate(UserBase): """Validating fields types""" + password: str confirm_password: str @@ -37,42 +51,48 @@ class UserCreate(UserBase): 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') - def passwords_match( - cls, confirm_password: str, - values: UserBase) -> Union[ValueError, str]: + "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) -> 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') - def username_length(cls, username: str) -> Union[ValueError, str]: + @validator("username") + def username_length(cls, username: str) -> 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") + if username.startswith("@"): + raise ValueError("username can not start with '@'") return username - @validator('password') - def password_length(cls, password: str) -> Union[ValueError, str]: + @validator("password") + def password_length(cls, password: str) -> 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') - def confirm_mail(cls, email: str) -> Union[ValueError, str]: + @validator("email") + def confirm_mail(cls, email: str) -> str: """Validating email is valid mail address.""" try: EmailStr.validate(email) @@ -86,5 +106,26 @@ class User(UserBase): Validating fields types Returns a User object without sensitive information """ + id: int is_active: bool + + +class NoteSchema(BaseModel): + title: str + description: Optional[str] = None + timestamp: Optional[datetime] + creator: Optional[User] + + class Config: + orm_mode = True + schema_extra = { + "example": { + "title": "Foo", + "description": "Bar", + }, + } + + +class NoteDB(NoteSchema): + id: int diff --git a/app/dependencies.py b/app/dependencies.py index 69a6ca42..01cdcf56 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -13,17 +13,30 @@ MEDIA_PATH = os.path.join(APP_PATH, config.MEDIA_DIRECTORY) STATIC_PATH = os.path.join(APP_PATH, "static") TEMPLATES_PATH = os.path.join(APP_PATH, "templates") - +SOUNDS_PATH = os.path.join(STATIC_PATH, "tracks") templates = Jinja2Templates(directory=TEMPLATES_PATH) -templates.env.add_extension('jinja2.ext.i18n') +templates.env.add_extension("jinja2.ext.i18n") + # Configure logger -logger = LoggerCustomizer.make_logger(config.LOG_PATH, - config.LOG_FILENAME, - config.LOG_LEVEL, - config.LOG_ROTATION_INTERVAL, - config.LOG_RETENTION_INTERVAL, - config.LOG_FORMAT) +logger = LoggerCustomizer.make_logger( + config.LOG_PATH, + config.LOG_FILENAME, + config.LOG_LEVEL, + config.LOG_ROTATION_INTERVAL, + config.LOG_RETENTION_INTERVAL, + config.LOG_FORMAT, +) + +if os.path.isdir(config.UPLOAD_DIRECTORY): + UPLOAD_PATH = config.UPLOAD_DIRECTORY +else: + try: + UPLOAD_PATH = os.path.join(os.getcwd(), config.UPLOAD_DIRECTORY) + os.mkdir(UPLOAD_PATH) + except OSError as e: + logger.critical(e) + raise OSError(e) def get_db() -> Session: diff --git a/app/internal/agenda_events.py b/app/internal/agenda_events.py index a7053a46..b77e2a0b 100644 --- a/app/internal/agenda_events.py +++ b/app/internal/agenda_events.py @@ -1,20 +1,20 @@ import datetime from datetime import date, timedelta -from typing import Iterator, List, Optional, Union +from typing import Dict, Iterator, List, Optional, Tuple, Union import arrow from sqlalchemy.orm import Session from app.database.models import Event +from app.internal.user import user from app.routers.event import sort_by_date -from app.routers.user import get_all_user_events def get_events_per_dates( - session: Session, - user_id: int, - start: Optional[date], - end: Optional[date] + session: Session, + user_id: int, + start: Optional[date], + end: Optional[date], ) -> Union[Iterator[Event], list]: """Read from the db. Return a list of all the user events between the relevant dates.""" @@ -22,14 +22,10 @@ def get_events_per_dates( if start > end: return [] - return ( - filter_dates( - sort_by_date( - get_all_user_events(session, user_id) - ), - start, - end, - ) + return filter_dates( + sort_by_date(user.get_all_user_events(session, user_id)), + start, + end, ) @@ -54,15 +50,17 @@ def get_time_delta_string(start: date, end: date) -> str: diff = end - start granularity = build_arrow_delta_granularity(diff) duration_string = arrow_end.humanize( - arrow_start, only_distance=True, granularity=granularity + arrow_start, + only_distance=True, + granularity=granularity, ) return duration_string def filter_dates( - events: List[Event], - start: Union[None, date] = None, - end: Union[None, date] = None, + events: List[Event], + start: Union[None, date] = None, + end: Union[None, date] = None, ) -> Iterator[Event]: """Returns all events in a time frame. @@ -82,10 +80,36 @@ def filter_dates( def get_events_in_time_frame( - start_date: Union[date, None], - end_date: Union[date, None], - user_id: int, db: Session + start_date: Union[date, None], + end_date: Union[date, None], + user_id: int, + db: Session, ) -> Iterator[Event]: """Yields all user's events in a time frame.""" - events = get_all_user_events(db, user_id) + events = user.get_all_user_events(db, user_id) yield from filter_dates(events, start_date, end_date) + + +def get_events_for_the_week( + db: Session, + user_id: int, +) -> Tuple[Union[Iterator[Event], list], Dict]: + WEEK_DAYS = 7 + start_date = date.today() + end_date = start_date + timedelta(days=WEEK_DAYS - 1) + + events_this_week = get_events_per_dates(db, user_id, start_date, end_date) + events_for_graph = { + str(start_date + timedelta(i)): 0 for i in range(WEEK_DAYS) + } + return events_this_week, events_for_graph + + +def make_dict_for_graph_data(db: Session, user_id: int) -> Dict[str, int]: + """create a dict with number of events per day for current week""" + events_this_week, events_for_graph = get_events_for_the_week(db, user_id) + + for event_obj in events_this_week: + event_date = event_obj.start.date() + events_for_graph[str(event_date)] += 1 + return events_for_graph diff --git a/app/internal/astronomy.py b/app/internal/astronomy.py index 4c150c52..435ce935 100644 --- a/app/internal/astronomy.py +++ b/app/internal/astronomy.py @@ -1,5 +1,5 @@ -from datetime import datetime import functools +from datetime import datetime from typing import Any, Dict import httpx @@ -12,7 +12,8 @@ async def get_astronomical_data( - date: datetime, location: str + date: datetime, + location: str, ) -> Dict[str, Any]: """Returns astronomical data (sun and moon) for date and location. @@ -30,13 +31,14 @@ async def get_astronomical_data( sunrise, sunset, moonrise, moonset, moon_phase, and moon_illumination. """ - formatted_date = date.strftime('%Y-%m-%d') + formatted_date = date.strftime("%Y-%m-%d") return await _get_astronomical_data_from_api(formatted_date, location) @functools.lru_cache(maxsize=128) async def _get_astronomical_data_from_api( - date: str, location: str + date: str, + location: str, ) -> Dict[str, Any]: """Returns astronomical_data from a Weather API call. @@ -48,16 +50,18 @@ async def _get_astronomical_data_from_api( A dictionary with the results from the API call. """ input_query_string = { - 'key': config.ASTRONOMY_API_KEY, - 'q': location, - 'dt': date, + "key": config.ASTRONOMY_API_KEY, + "q": location, + "dt": date, } output: Dict[str, Any] = {} try: async with httpx.AsyncClient() as client: response = await client.get( - ASTRONOMY_URL, params=input_query_string) + ASTRONOMY_URL, + params=input_query_string, + ) except httpx.HTTPError: output["success"] = False output["error"] = NO_API_RESPONSE @@ -70,9 +74,9 @@ async def _get_astronomical_data_from_api( output["success"] = True try: - output.update(response.json()['location']) + output.update(response.json()["location"]) return output except KeyError: output["success"] = False - output["error"] = response.json()['error']['message'] + output["error"] = response.json()["error"]["message"] return output diff --git a/app/internal/audio.py b/app/internal/audio.py new file mode 100644 index 00000000..48449c33 --- /dev/null +++ b/app/internal/audio.py @@ -0,0 +1,247 @@ +from enum import Enum +from typing import Dict, List, Optional, Tuple, Union + +from sqlalchemy.orm.session import Session + +from app.database.models import ( + AudioTracks, + User, + UserAudioTracks, + UserSettings, +) + +DEFAULT_MUSIC = ["GASTRONOMICA.mp3"] +DEFAULT_MUSIC_VOL = 0.5 +DEFAULT_SFX = "click_1.wav" +DEFAULT_SFX_VOL = 0.5 + + +class SoundKind(Enum): + SONG = 1 + SFX = 0 + + +class Sound: + def __init__(self, sound_kind, name, src): + self.sound_kind = sound_kind + self.name = name + self.src = src + + +def init_audio_tracks(session: Session, sounds: List[Sound]): + """This function fills the AudioTracks table + + Args: + session (Session): the database + sounds (List[Sound]): list of sounds + """ + for sound in sounds: + add_sound(session, sound) + + +def add_sound(session: Session, sound: Sound): + """Adds a new audio track to AudioTracks table. + + Args: + session (Session): the databse. + sound (Sound): song or sfx. + """ + res = session.query(AudioTracks).filter_by(title=sound.name).first() + if not res: + track = AudioTracks(title=sound.name, is_music=sound.sound_kind.value) + session.add(track) + session.commit() + + +def get_tracks( + session: Session, + user_id: int, +) -> Tuple[List[str], Optional[str]]: + """Retrieves audio selections from the database, + for both music and sound effects. + + Args: + session (Session): the database. + user_id (int): current users' id. + + Returns: + Tuple[Optional[List[str]], Optional[str]]: + returns the playlist of music tracks, as well as sound effect choice. + """ + playlist = [] + + chosen_track_ids = session.query(UserAudioTracks.track_id).filter_by( + user_id=user_id, + ) + + tracks = ( + session.query(AudioTracks) + .filter(AudioTracks.id.in_(chosen_track_ids)) + .filter_by(is_music=1) + ) + + sfx = ( + session.query(AudioTracks) + .filter(AudioTracks.id.in_(chosen_track_ids)) + .filter_by(is_music=0) + .first() + ) + + for track in tracks: + playlist.append(track.title + ".mp3") + sfx_choice = sfx.title + ".wav" if sfx else None + + return playlist, sfx_choice + + +def get_audio_settings( + session: Session, + user_id: int, +) -> Tuple[Optional[List[str]], Optional[int], Optional[str], Optional[int]]: + """Retrieves audio settings from the database. + + Args: + session (Session): [description] + user_id (int, optional): [description]. Defaults to 1. + + Returns: + Tuple[str, Optional[List[str]], Optional[int], + str, Optional[str], Optional[int]]: the audio settings. + """ + music_on, music_vol, sfx_on, sfx_vol = None, None, None, None + playlist, sfx_choice = get_tracks(session, user_id) + audio_settings = ( + session.query(UserSettings).filter_by(user_id=user_id).first() + ) + if audio_settings: + music_on = audio_settings.music_on + music_vol = audio_settings.music_vol + sfx_on = audio_settings.sfx_on + sfx_vol = audio_settings.sfx_vol + + return music_on, playlist, music_vol, sfx_on, sfx_choice, sfx_vol + + +def handle_vol( + is_audio_on: bool, + vol: Optional[int], +) -> Optional[int]: + """Helper function that normalizes the volume and returns it, + if audio is on. + + Args: + is_audio_on (bool): True if the user chose to enable, False otherwise. + vol (Optional[int]): a number in the range (0, 1), + indicating the volume. + example: 0.4 means 40% of the tracks' volume. + + Returns: + Optional[int]: returns the normalized volume, or None if audio + is disabled. + """ + if is_audio_on: + vol /= 100 + + return vol + + +# Functions for saving users' choices in the db. + + +def save_audio_settings( + session: Session, + music_choices: Optional[List[str]], + sfx_choice: Optional[str], + user_choices: Dict[str, Union[str, int]], + user: User, +): + """Save audio settings in the db. + + Args: + session (Session): the database + music_choices (Optional[List[str]]): a list of music tracks + if music is enabled, None otherwise. + sfx_choice (Optional[str]): choice for sound effect. + user_choices (Dict[str, Union[str, int]]): + including music_on, music_vol, sfx_on, sfx_vol + user (User): current user + """ + handle_audio_settings(session, user.user_id, user_choices) + handle_user_audio_tracks(session, user.user_id, music_choices, sfx_choice) + + +def handle_audio_settings( + session: Session, + user_id: int, + user_choices: Dict[str, Union[str, int]], +): + """Insert or update a new record into UserSettings table. + The table stores the following information: + music on, music_vol, sfx on, sfx_vol. + + Args: + session (Session): the database + user_id (int): current users' id. + user_choices (Dict[str, Union[str, int]]): + including music_on, music_vol, sfx_on, sfx_vol + """ + audio_settings = ( + session.query(UserSettings).filter_by(user_id=user_id).first() + ) + if not audio_settings: + audio_settings = UserSettings(user_id=user_id, **user_choices) + session.add(audio_settings) + + else: + session.query(UserSettings).filter_by( + user_id=audio_settings.user_id, + ).update(user_choices) + + session.commit() + + +def handle_user_audio_tracks( + session: Session, + user_id: int, + music_choices: Optional[List[str]], + sfx_choice: Optional[str], +): + """[summary] + + Args: + session (Session): the database. + user_id (int): current users' id. + music_choices (Optional[List[str]]): + a list of music tracks if music is enabled, None otherwise. + sfx_choice (Optional[str]): choice for sound effect. + """ + user_audio_tracks = session.query(UserAudioTracks).filter_by( + user_id=user_id, + ) + if user_audio_tracks: + for record in user_audio_tracks: + session.delete(record) + session.commit() + + if music_choices: + for track in music_choices: + create_new_user_audio_record(session, track, user_id) + if sfx_choice: + create_new_user_audio_record(session, sfx_choice, user_id) + + +def create_new_user_audio_record(session: Session, choice, user_id: int): + """Creates a new UserAudioTracks record. + This is the table that connects users and audio_tracks tables. + + Args: + session (Session): the database. + choice ([type]): title of music track or sound effect. + user_id (int): current users' id. + """ + choice = choice.split(".", maxsplit=1)[0] + track = session.query(AudioTracks).filter_by(title=choice).first() + track_id = track.id + record = UserAudioTracks(user_id=user_id, track_id=track_id) + session.add(record) + session.commit() diff --git a/app/internal/calendar_privacy.py b/app/internal/calendar_privacy.py index d827f0e4..250a8620 100644 --- a/app/internal/calendar_privacy.py +++ b/app/internal/calendar_privacy.py @@ -1,32 +1,34 @@ -from app.dependencies import get_db +from fastapi import Depends + from app.database.models import User +from app.dependencies import get_db +from app.internal.privacy import PrivacyKinds + # TODO switch to using this when the user system is merged -# from app.internal.security.dependancies import ( +# from app.internal.security.dependencies import ( # current_user, CurrentUser) -from fastapi import Depends - # TODO add privacy as an attribute in current user -# in app.internal.security.dependancies +# in app.internal.security.dependencies # when user system is merged def can_show_calendar( requested_user_username: str, db: Depends(get_db), - current_user: User + current_user: User, # TODO to be added after user system is merged: # CurrentUser = Depends(current_user) ) -> bool: """Check whether current user can show the requested calendar""" - requested_user = db.query(User).filter( - User.username == requested_user_username - ).first() + requested_user = ( + db.query(User).filter(User.username == requested_user_username).first() + ) privacy = current_user.privacy is_current_user = current_user.username == requested_user.username - if privacy == 'Private' and is_current_user: + if privacy == PrivacyKinds.Private.name and is_current_user: return True - elif privacy == 'Public': + elif privacy == PrivacyKinds.Public.name: return True return False diff --git a/app/internal/corona_stats.py b/app/internal/corona_stats.py new file mode 100644 index 00000000..763f0a89 --- /dev/null +++ b/app/internal/corona_stats.py @@ -0,0 +1,153 @@ +import json +import random +from datetime import date, datetime +from typing import Any, Dict + +import httpx +from fastapi import Depends +from loguru import logger +from sqlalchemy import desc, func +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound + +from app.database.models import CoronaStats +from app.dependencies import get_db + +DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" +CORONA_API_URL = ( + "https://datadashboardapi.health.gov.il/api/queries/vaccinated" +) +USER_AGENT_OPTIONS = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5)" + " AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0)" + " Gecko/20100101 Firefox/77.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:77.0) " + "Gecko/20100101 Firefox/77.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/83.0.4103.97 Safari/537.36", +] + + +def create_stats_object(corona_stats_data: Dict[str, Any]) -> CoronaStats: + """Dict -> DB Object""" + return CoronaStats( + date_=datetime.strptime( + corona_stats_data.get("Day_Date"), + DATETIME_FORMAT, + ), + vaccinated=corona_stats_data.get("vaccinated"), + vaccinated_total=corona_stats_data.get("vaccinated_cum"), + vaccinated_population_perc=corona_stats_data.get( + "vaccinated_population_perc", + ), + vaccinated_second_dose=corona_stats_data.get( + "vaccinated_seconde_dose", + ), + vaccinated_second_dose_total=corona_stats_data.get( + "vaccinated_seconde_dose_cum", + ), + vaccinated_second_dose_perc=corona_stats_data.get( + "vaccinated_seconde_dose_population_perc", + ), + ) + + +def serialize_stats(stats_object: CoronaStats) -> Dict[str, Any]: + """ DB Object -> Dict """ + return { + "vaccinated_second_dose_perc": ( + stats_object.vaccinated_second_dose_perc + ), + "vaccinated_second_dose_total": ( + stats_object.vaccinated_second_dose_total + ), + } + + +def serialize_dict_stats(stats_dict: Dict[str, Any]) -> Dict[str, Any]: + """ api Dict -> pylender Dict """ + return { + "vaccinated_second_dose_perc": ( + stats_dict.get("vaccinated_seconde_dose_population_perc") + ), + "vaccinated_second_dose_total": ( + stats_dict.get("vaccinated_seconde_dose_cum") + ), + } + + +def save_corona_stats( + corona_stats_data: Dict[str, Any], + db: Session = Depends(get_db), +) -> None: + db.add(create_stats_object(corona_stats_data)) + db.commit() + + +def insert_to_db_if_needed( + corona_stats_data: Dict[str, Any], + db: Session = Depends(get_db), +) -> Dict[str, Any]: + """ gets the latest data inserted to gov database """ + latest_date = datetime.strptime( + corona_stats_data.get("Day_Date"), + DATETIME_FORMAT, + ) + latest_saved = None + try: + latest_saved = ( + db.query(CoronaStats).order_by(desc(CoronaStats.date_)).one() + ) + except NoResultFound: + # on first system load, the table is empty + save_corona_stats(corona_stats_data, db) + return corona_stats_data + + if latest_saved is not None: + # on more recent data arrival, we update the database + if latest_saved.date_ < latest_date: + save_corona_stats(corona_stats_data, db) + return corona_stats_data + else: + return serialize_stats(latest_saved) + + +async def get_vacinated_data() -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + headers = {"User-Agent": random.choice(USER_AGENT_OPTIONS)} + res = await client.get(CORONA_API_URL, headers=headers) + return json.loads(res.text)[-1] + + +def get_vacinated_data_from_db(db: Session = Depends(get_db)) -> CoronaStats: + # pulls once a day, it won't be the most updated data + # but we dont want to be blocked for too many requests + return ( + db.query(CoronaStats) + .filter(func.date(CoronaStats.date_inserted) == date.today()) + .one() + ) + + +async def get_corona_stats(db: Session = Depends(get_db)) -> Dict[str, Any]: + try: + db_data = get_vacinated_data_from_db(db) + corona_stats_data = serialize_stats(db_data) + + except NoResultFound: + try: + response_data = await get_vacinated_data() + insert_to_db_if_needed(response_data, db) + corona_stats_data = serialize_dict_stats(response_data) + except json.decoder.JSONDecodeError: + corona_stats_data = {"error": "No data"} + + except (SQLAlchemyError, AttributeError) as e: + logger.exception(f"corona stats failed with error: {e}") + corona_stats_data = {"error": "No data"} + return corona_stats_data diff --git a/app/internal/email.py b/app/internal/email.py index 87092f7f..d75e1e09 100644 --- a/app/internal/email.py +++ b/app/internal/email.py @@ -7,16 +7,26 @@ from pydantic.errors import EmailError from sqlalchemy.orm.session import Session -from app.config import (CALENDAR_HOME_PAGE, CALENDAR_REGISTRATION_PAGE, - CALENDAR_SITE_NAME, email_conf, templates) +from app.config import ( + CALENDAR_HOME_PAGE, + CALENDAR_REGISTRATION_PAGE, + CALENDAR_SITE_NAME, + DOMAIN, + email_conf, +) from app.database.models import Event, User +from app.dependencies import templates +from app.internal.security.schema import ForgotPassword mail = FastMail(email_conf) def send( - session: Session, event_used: int, user_to_send: int, - title: str, background_tasks: BackgroundTasks = BackgroundTasks + session: Session, + event_used: int, + user_to_send: int, + title: str, + background_tasks: BackgroundTasks = BackgroundTasks, ) -> bool: """This function is being used to send emails in the background. It takes an event and a user and it sends the event to the user. @@ -32,10 +42,8 @@ def send( Returns: bool: Returns True if the email was sent, else returns False. """ - event_used = session.query(Event).filter( - Event.id == event_used).first() - user_to_send = session.query(User).filter( - User.id == user_to_send).first() + event_used = session.query(Event).filter(Event.id == event_used).first() + user_to_send = session.query(User).filter(User.id == user_to_send).first() if not user_to_send or not event_used: return False if not verify_email_pattern(user_to_send.email): @@ -45,18 +53,21 @@ def send( recipients = {"email": [user_to_send.email]}.get("email") body = f"begins at:{event_used.start} : {event_used.content}" - background_tasks.add_task(send_internal, - subject=subject, - recipients=recipients, - body=body) + background_tasks.add_task( + send_internal, + subject=subject, + recipients=recipients, + body=body, + ) return True -def send_email_invitation(sender_name: str, - recipient_name: str, - recipient_mail: str, - background_tasks: BackgroundTasks = BackgroundTasks - ) -> bool: +def send_email_invitation( + sender_name: str, + recipient_name: str, + recipient_mail: str, + background_tasks: BackgroundTasks = BackgroundTasks, +) -> bool: """ This function takes as parameters the sender's name, the recipient's name and his email address, configuration, and @@ -81,28 +92,35 @@ def send_email_invitation(sender_name: str, return False template = templates.get_template("invite_mail.html") - html = template.render(recipient=recipient_name, sender=sender_name, - site_name=CALENDAR_SITE_NAME, - registration_link=CALENDAR_REGISTRATION_PAGE, - home_link=CALENDAR_HOME_PAGE, - addr_to=recipient_mail) + html = template.render( + recipient=recipient_name, + sender=sender_name, + site_name=CALENDAR_SITE_NAME, + registration_link=CALENDAR_REGISTRATION_PAGE, + home_link=CALENDAR_HOME_PAGE, + addr_to=recipient_mail, + ) subject = "Invitation" recipients = [recipient_mail] body = html subtype = "html" - background_tasks.add_task(send_internal, - subject=subject, - recipients=recipients, - body=body, - subtype=subtype) + background_tasks.add_task( + send_internal, + subject=subject, + recipients=recipients, + body=body, + subtype=subtype, + ) return True -def send_email_file(file_path: str, - recipient_mail: str, - background_tasks: BackgroundTasks = BackgroundTasks): +def send_email_file( + file_path: str, + recipient_mail: str, + background_tasks: BackgroundTasks = BackgroundTasks, +): """ his function takes as parameters the file's path, the recipient's email address, configuration, and @@ -126,19 +144,23 @@ def send_email_file(file_path: str, body = "file" file_attachments = [file_path] - background_tasks.add_task(send_internal, - subject=subject, - recipients=recipients, - body=body, - file_attachments=file_attachments) + background_tasks.add_task( + send_internal, + subject=subject, + recipients=recipients, + body=body, + file_attachments=file_attachments, + ) return True -async def send_internal(subject: str, - recipients: List[str], - body: str, - subtype: Optional[str] = None, - file_attachments: Optional[List[str]] = None): +async def send_internal( + subject: str, + recipients: List[str], + body: str, + subtype: Optional[str] = None, + file_attachments: Optional[List[str]] = None, +): if file_attachments is None: file_attachments = [] @@ -147,8 +169,10 @@ async def send_internal(subject: str, recipients=[EmailStr(recipient) for recipient in recipients], body=body, subtype=subtype, - attachments=[UploadFile(file_attachment) - for file_attachment in file_attachments]) + attachments=[ + UploadFile(file_attachment) for file_attachment in file_attachments + ], + ) return await send_internal_internal(message) @@ -177,3 +201,32 @@ def verify_email_pattern(email: str) -> bool: return True except EmailError: return False + + +async def send_reset_password_mail( + user: ForgotPassword, + background_tasks: BackgroundTasks, +) -> bool: + """ + This function sends a reset password email to user. + :param user: ForgotPassword schema. + Contains user's email address, jwt verifying token. + :param background_tasks: (BackgroundTasks): Function from fastapi that lets + you apply tasks in the background. + returns True + """ + params = f"?email_verification_token={user.email_verification_token}" + template = templates.get_template("reset_password_mail.html") + html = template.render( + recipient=user.username.lstrip("@"), + link=f"{DOMAIN}/reset-password{params}", + email=user.email, + ) + background_tasks.add_task( + send_internal, + subject="Calendar reset password", + recipients=[user.email], + body=html, + subtype="html", + ) + return True diff --git a/app/internal/emotion.py b/app/internal/emotion.py index 950b9c31..32dc0a35 100644 --- a/app/internal/emotion.py +++ b/app/internal/emotion.py @@ -1,41 +1,55 @@ -import text2emotion as te from typing import Dict, NamedTuple, Union -from app.config import ( - CONTENT_WEIGHTS, - LEVEL_OF_SIGNIFICANCE, - TITLE_WEIGHTS) +import text2emotion as te +from app.config import CONTENT_WEIGHTS, LEVEL_OF_SIGNIFICANCE, TITLE_WEIGHTS -EMOTIONS = {"Happy": "😃", - "Sad": "🙁", - "Angry": "😠", - "Fear": "😱", - "Surprise": "😮"} +EMOTIONS = { + "Happy": "😃", + "Sad": "🙁", + "Angry": "😠", + "Fear": "😱", + "Surprise": "😮", +} -Emoticon = NamedTuple("Emoticon", [("dominant", str), ("score", float), - ("code", str)]) +Emoticon = NamedTuple( + "Emoticon", + [("dominant", str), ("score", float), ("code", str)], +) DupEmotion = NamedTuple("DupEmotion", [("dominant", str), ("flag", bool)]) -def get_weight(emotion: str, title_emotion: Dict[str, float], - content_emotion: Dict[str, float] = None) -> float: +def get_weight( + emotion: str, + title_emotion: Dict[str, float], + content_emotion: Dict[str, float] = None, +) -> float: if not content_emotion: return title_emotion[emotion] - return (title_emotion[emotion] * TITLE_WEIGHTS + - content_emotion[emotion] * CONTENT_WEIGHTS) - - -def score_comp(emotion_score: float, dominant_emotion: Emoticon, emotion: str, - code: str, flag: bool) -> DupEmotion: + return ( + title_emotion[emotion] * TITLE_WEIGHTS + + content_emotion[emotion] * CONTENT_WEIGHTS + ) + + +def score_comp( + emotion_score: float, + dominant_emotion: Emoticon, + emotion: str, + code: str, + flag: bool, +) -> DupEmotion: """ score comparison between emotions. returns the dominant and if equals we flag it """ if emotion_score > dominant_emotion.score: flag = False - dominant_emotion = Emoticon(dominant=emotion, score=emotion_score, - code=code) + dominant_emotion = Emoticon( + dominant=emotion, + score=emotion_score, + code=code, + ) elif emotion_score == dominant_emotion.score: flag = True return DupEmotion(dominant=dominant_emotion, flag=flag) @@ -58,17 +72,23 @@ def get_dominant_emotion(title: str, content: str) -> Emoticon: if has_content: weight_parameters.append(content_emotion) emotion_score = get_weight(*weight_parameters) - score_comparison = score_comp(emotion_score, dominant_emotion, emotion, - code, duplicate_dominant_flag) + score_comparison = score_comp( + emotion_score, + dominant_emotion, + emotion, + code, + duplicate_dominant_flag, + ) dominant_emotion, duplicate_dominant_flag = [*score_comparison] if duplicate_dominant_flag: return Emoticon(dominant=None, score=0, code=None) return dominant_emotion -def is_emotion_above_significance(dominant_emotion: Emoticon, - significance: float = - LEVEL_OF_SIGNIFICANCE) -> bool: +def is_emotion_above_significance( + dominant_emotion: Emoticon, + significance: float = LEVEL_OF_SIGNIFICANCE, +) -> bool: """ get the dominant emotion, emotion score and emoticon code and check if the emotion score above the constrain we set diff --git a/app/internal/event.py b/app/internal/event.py index 5207761e..3d4bebfe 100644 --- a/app/internal/event.py +++ b/app/internal/event.py @@ -1,54 +1,76 @@ +import functools import logging import re -from typing import List, Set +from typing import List, NamedTuple, Set, Union from email_validator import EmailSyntaxError, validate_email from fastapi import HTTPException +from geopy.adapters import AioHTTPAdapter +from geopy.exc import GeocoderTimedOut, GeocoderUnavailable +from geopy.geocoders import Nominatim +from loguru import logger from sqlalchemy.orm import Session from starlette.status import HTTP_400_BAD_REQUEST -from app.database.models import Event +from app.database.models import Country, Event +from app.resources.countries import countries -ZOOM_REGEX = re.compile(r'https://.*?\.zoom.us/[a-z]/.[^.,\b\s]+') +ZOOM_REGEX = re.compile(r"https://.*?\.zoom.us/[a-z]/.[^.,\b\s]+") + + +class Location(NamedTuple): + # Location type hint class. + latitude: str + longitude: str + name: str def raise_if_zoom_link_invalid(vc_link): if ZOOM_REGEX.search(vc_link) is None: - raise HTTPException(status_code=HTTP_400_BAD_REQUEST, - detail="VC type with no valid zoom link") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail="VC type with no valid zoom link", + ) def get_invited_emails(invited_from_form: str) -> List[str]: invited_emails = [] - for invited_email in invited_from_form.split(','): + if not invited_from_form: + return [""] + for invited_email in invited_from_form.split(","): invited_email = invited_email.strip() try: validate_email(invited_email, check_deliverability=False) except EmailSyntaxError: - logging.exception(f'{invited_email} is not a valid email address') - continue - invited_emails.append(invited_email) + logging.exception( + f"{invited_email} is not a valid email address", + ) + else: + invited_emails.append(invited_email) return invited_emails -def get_uninvited_regular_emails(session: Session, - owner_id: int, - title: str, - invited_emails: List[str]) -> Set[str]: +def get_uninvited_regular_emails( + session: Session, + owner_id: int, + title: str, + invited_emails: List[str], +) -> Set[str]: invitees_query = session.query(Event).with_entities(Event.invitees) - similar_events_invitees = invitees_query.filter(Event.owner_id == owner_id, - Event.title == title).all() + similar_events_invitees = invitees_query.filter( + Event.owner_id == owner_id, + Event.title == title, + ).all() regular_invitees = set() for record in similar_events_invitees: if record: - regular_invitees.update(record[0].split(',')) + regular_invitees.update(record[0].split(",")) return regular_invitees - set(invited_emails) -def check_diffs(checked_event: Event, - all_events: List[Event]): +def check_diffs(checked_event: Event, all_events: List[Event]): """Returns the repeated events and the week difference""" diffs = [] for event in all_events: @@ -65,22 +87,91 @@ def check_diffs(checked_event: Event, def find_pattern(session, event): - all_events_with_same_name = session.query(Event).filter( - Event.owner_id == event.owner_id, Event.title == event.title).all() + all_events_with_same_name = ( + session.query(Event) + .filter(Event.owner_id == event.owner_id, Event.title == event.title) + .all() + ) return check_diffs(event, all_events_with_same_name) -def get_messages(session: Session, - event: Event, - uninvited_contacts: Set[str]) -> List[str]: +def get_messages( + session: Session, + event: Event, + uninvited_contacts: Set[str], +) -> List[str]: messages = [] if uninvited_contacts: - messages.append(f'Forgot to invite ' - f'{", ".join(uninvited_contacts)} maybe?') + messages.append( + f"Forgot to invite " f'{", ".join(uninvited_contacts)} maybe?', + ) pattern = find_pattern(session, event) for weeks_diff in pattern: - messages.append(f'Same event happened {weeks_diff} weeks before too. ' - f'Want to create another one {weeks_diff} after too?') + messages.append( + f"Same event happened {weeks_diff} weeks before too. " + f"Want to create another one {weeks_diff} after too?", + ) return messages + + +def add_countries_to_db(session: Session) -> None: + """ + Adding all new countries to the "Country" table in the database. + Information is based on the "countries" list. + (The list is located in app/resources/countries.py) + Names are described either as: + "Country Name, City Name" or + "Country Name" solely. + Timezones are described as "Continent/ City Name" + for example: + name: Israel, Jerusalem + timezone: Asia/Jerusalem + """ + for country in countries: + partial_name = country["name"] + for capital_city in country["timezones"]: + capital_city_name = capital_city.split("/")[-1] + if partial_name != capital_city_name: + name = partial_name + ", " + capital_city_name + else: + name = capital_city_name + new_country = Country(name=name, timezone=str(capital_city)) + session.merge(new_country) + session.commit() + + +@functools.lru_cache(maxsize=None) +def get_all_countries_names(session: Session) -> List[str]: + """ + Returns a cached list of the countries names. + """ + db_entity = session.query(Country).first() + if not db_entity: + add_countries_to_db(session=session) + return session.query(Country.name).all() + + +async def get_location_coordinates( + address: str, +) -> Union[Location, str]: + """Return location coordinates and accurate + address of the specified location.""" + try: + async with Nominatim( + user_agent="Pylendar", + adapter_factory=AioHTTPAdapter, + ) as geolocator: + geolocation = await geolocator.geocode(address) + except (GeocoderTimedOut, GeocoderUnavailable) as e: + logger.exception(str(e)) + else: + if geolocation is not None: + location = Location( + latitude=geolocation.latitude, + longitude=geolocation.longitude, + name=geolocation.raw["display_name"], + ) + return location + return address diff --git a/app/internal/export.py b/app/internal/export.py index 4736a721..99c64af7 100644 --- a/app/internal/export.py +++ b/app/internal/export.py @@ -1,8 +1,10 @@ from datetime import datetime from typing import List -from icalendar import Calendar, Event as IEvent, vCalAddress, vText import pytz +from icalendar import Calendar +from icalendar import Event as IEvent +from icalendar import vCalAddress, vText from sqlalchemy.orm import Session from app.config import DOMAIN, ICAL_VERSION, PRODUCT_ID @@ -31,7 +33,8 @@ def get_icalendar(event: Event, emails: List[str]) -> bytes: def get_icalendar_with_multiple_events( - session: Session, events: List[Event] + session: Session, + events: List[Event], ) -> bytes: """Returns an iCalendar event in bytes. @@ -58,8 +61,8 @@ def get_icalendar_with_multiple_events( def _create_icalendar() -> Calendar: """Returns an iCalendar.""" calendar = Calendar() - calendar.add('version', ICAL_VERSION) - calendar.add('prodid', PRODUCT_ID) + calendar.add("version", ICAL_VERSION) + calendar.add("prodid", PRODUCT_ID) return calendar @@ -92,19 +95,19 @@ def _create_icalendar_event(event: Event) -> IEvent: An iCalendar event. """ data = [ - ('organizer', _get_v_cal_address(event.owner.email, organizer=True)), - ('uid', _generate_id(event)), - ('dtstart', event.start), - ('dtstamp', datetime.now(tz=pytz.utc)), - ('dtend', event.end), - ('summary', event.title), + ("organizer", _get_v_cal_address(event.owner.email, organizer=True)), + ("uid", _generate_id(event)), + ("dtstart", event.start), + ("dtstamp", datetime.now(tz=pytz.utc)), + ("dtend", event.end), + ("summary", event.title), ] if event.location: - data.append(('location', event.location)) + data.append(("location", event.location)) if event.content: - data.append(('description', event.content)) + data.append(("description", event.content)) ievent = IEvent() for param in data: @@ -123,13 +126,13 @@ def _get_v_cal_address(email: str, organizer: bool = False) -> vCalAddress: Returns: A vCalAddress object. """ - attendee = vCalAddress(f'MAILTO:{email}') + attendee = vCalAddress(f"MAILTO:{email}") if organizer: - attendee.params['partstat'] = vText('ACCEPTED') - attendee.params['role'] = vText('CHAIR') + attendee.params["partstat"] = vText("ACCEPTED") + attendee.params["role"] = vText("CHAIR") else: - attendee.params['partstat'] = vText('NEEDS-ACTION') - attendee.params['role'] = vText('PARTICIPANT') + attendee.params["partstat"] = vText("NEEDS-ACTION") + attendee.params["role"] = vText("PARTICIPANT") return attendee @@ -147,10 +150,10 @@ def _generate_id(event: Event) -> bytes: A unique encoded ID in bytes. """ return ( - str(event.id) - + event.start.strftime('%Y%m%d') - + event.end.strftime('%Y%m%d') - + f'@{DOMAIN}' + str(event.id) + + event.start.strftime("%Y%m%d") + + event.end.strftime("%Y%m%d") + + f"@{DOMAIN}" ).encode() @@ -163,4 +166,4 @@ def _add_attendees(ievent: IEvent, emails: List[str]): """ for email in emails: if verify_email_pattern(email): - ievent.add('attendee', _get_v_cal_address(email), encode=0) + ievent.add("attendee", _get_v_cal_address(email), encode=0) diff --git a/app/internal/features.py b/app/internal/features.py new file mode 100644 index 00000000..4ef8628e --- /dev/null +++ b/app/internal/features.py @@ -0,0 +1,183 @@ +from functools import wraps +from typing import Dict, List + +from fastapi import Depends, Request +from sqlalchemy.orm import Session +from sqlalchemy.sql import exists +from starlette.responses import RedirectResponse + +from app.database.models import Feature, UserFeature +from app.dependencies import SessionLocal, get_db +from app.internal.features_index import features +from app.internal.security.dependencies import current_user +from app.internal.security.ouath2 import get_authorization_cookie +from app.internal.utils import create_model + + +def feature_access_filter(call_next): + @wraps(call_next) + async def wrapper(*args, **kwargs): + request = kwargs["request"] + + if request.headers["user-agent"] == "testclient": + # in case it's a unit test. + return await call_next(*args, **kwargs) + + # getting the url route path for matching with the database. + route = "/" + str(request.url).replace(str(request.base_url), "") + + # getting access status. + access = await is_access_allowd(route=route, request=request) + + if access: + # in case the feature is enabled or access is allowed. + return await call_next(*args, **kwargs) + + elif "referer" not in request.headers: + # in case request come straight from address bar in browser. + return RedirectResponse(url="/") + + # in case the feature is disabled or access isn't allowed. + return RedirectResponse(url=request.headers["referer"]) + + return wrapper + + +def create_features_at_startup(session: Session) -> bool: + for feat in features: + if not is_feature_exists(feature=feat, session=session): + create_feature(**feat, db=session) + return True + + +def is_user_has_feature( + session: Session, + feature_id: int, + user_id: int, +) -> bool: + return session.query( + exists() + .where(UserFeature.user_id == user_id) + .where(UserFeature.feature_id == feature_id), + ).scalar() + + +def delete_feature( + feature: Feature, + session: Session = Depends(get_db), +) -> None: + session.query(UserFeature).filter_by(feature_id=feature.id).delete() + session.query(Feature).filter_by(id=feature.id).delete() + session.commit() + + +def is_feature_exists(feature: Dict[str, str], session: Session) -> bool: + is_exists = session.query( + exists() + .where(Feature.name == feature["name"]) + .where(Feature.route == feature["route"]), + ).scalar() + + return is_exists + + +def update_feature( + feature: Feature, + feature_dict: Dict[str, str], + session: Session = Depends(get_db), +) -> Feature: + feature.name = feature_dict["name"] + feature.route = feature_dict["route"] + feature.description = feature_dict["description"] + feature.creator = feature_dict["creator"] + session.commit() + return feature + + +async def is_access_allowd(request: Request, route: str) -> bool: + session = SessionLocal() + + # Get current user. + # Note: can't use dependency beacause its designed for routes only. + # current_user return schema not an db model. + jwt = await get_authorization_cookie(request=request) + user = await current_user(request=request, jwt=jwt, db=session) + + feature = session.query(Feature).filter_by(route=route).first() + + if feature is None: + # in case there is no feature exists in the database that match the + # route that gived by to the request. + return True + + user_feature = session.query( + exists().where( + (UserFeature.feature_id == feature.id) + & (UserFeature.user_id == user.user_id), + ), + ).scalar() + + return user_feature + + +def create_feature( + db: Session, + name: str, + route: str, + description: str, + creator: str = None, +) -> Feature: + """Creates a feature.""" + return create_model( + db, + Feature, + name=name, + route=route, + creator=creator, + description=description, + ) + + +def create_user_feature_association( + db: Session, + feature_id: int, + user_id: int, + is_enable: bool, +) -> UserFeature: + """Creates an association.""" + return create_model( + db, + UserFeature, + user_id=user_id, + feature_id=feature_id, + is_enable=is_enable, + ) + + +def get_user_installed_features( + user_id: int, + session: Session = Depends(get_db), +) -> List[Feature]: + return ( + session.query(Feature) + .join(UserFeature) + .filter(UserFeature.user_id == user_id) + .all() + ) + + +def get_user_uninstalled_features( + user_id: int, + session: Session = Depends(get_db), +) -> List[Feature]: + return ( + session.query(Feature) + .filter( + Feature.id.notin_( + session.query(UserFeature.feature_id).filter( + UserFeature.user_id == user_id, + ), + ), + ) + .all() + ) diff --git a/app/internal/features_index.py b/app/internal/features_index.py new file mode 100644 index 00000000..89743a2e --- /dev/null +++ b/app/internal/features_index.py @@ -0,0 +1,51 @@ +''' + This file purpose is for developers to add their features to the database + in one convenient place, every time the system loads up it's adding and + updating the features in the features table in the database. + + To update a feature, The developer needs to change the name or the route + and let the system load, but not change both at the same time otherwise + it will create junk and unnecessary duplicates. + + * IMPORTANT - To enable features panel functionlity the developer must * + * add the feature_access_filter decorator to ALL the feature routes * + * Please see the example below. * + + Enjoy and good luck :) +''' + +''' +Example to feature stracture: + +{ + "name": "", + "route": "/", + "description": "", + "creator": "" +} +''' + +''' +* IMPORTANT * + +Example to decorator placement: + + @router.get("/") + @feature_access_filter <---- just above def keyword! + def my_cool_feature_route(): + .... + ... + some code. + .. + . + +''' + +features = [ + { + "name": "Google Sync", + "route": "/google/sync", + "description": "Sync Google Calendar events with Pylender", + "creator": "Liran Caduri" + }, +] diff --git a/app/internal/friend_view.py b/app/internal/friend_view.py index 85e8190f..46cd011d 100644 --- a/app/internal/friend_view.py +++ b/app/internal/friend_view.py @@ -3,22 +3,22 @@ from sqlalchemy.orm import Session from app.database.models import Event +from app.internal.user import user from app.routers.event import sort_by_date -from app.routers.user import get_all_user_events def get_events_per_friend( - session: Session, - user_id: int, - my_friend: str, + session: Session, + user_id: int, + my_friend: str, ) -> List[Event]: - """ My_friend is the name of a person that appears in the invite list of + """My_friend is the name of a person that appears in the invite list of events. He is not necessarily a registered userץ The variable is used to show all events where we are both in the invitees list""" events_together = [] - sorted_events = sort_by_date(get_all_user_events(session, user_id)) + sorted_events = sort_by_date(user.get_all_user_events(session, user_id)) for event in sorted_events: - if my_friend in event.invitees.split(','): + if my_friend in event.invitees.split(","): events_together.append(event) return events_together diff --git a/app/internal/google_connect.py b/app/internal/google_connect.py index b04ef5e9..1a7e826f 100644 --- a/app/internal/google_connect.py +++ b/app/internal/google_connect.py @@ -1,63 +1,72 @@ from datetime import datetime -from fastapi import Depends +from fastapi import Depends from google.auth.transport.requests import Request as google_request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build -from app.database.models import Event, User, OAuthCredentials, UserEvent -from app.dependencies import get_db, SessionLocal from app.config import CLIENT_SECRET_FILE +from app.database.models import Event, OAuthCredentials, User, UserEvent +from app.dependencies import SessionLocal, get_db from app.routers.event import create_event - -SCOPES = ['https://www.googleapis.com/auth/calendar'] +SCOPES = ["https://www.googleapis.com/auth/calendar"] -def get_credentials(user: User, - session: SessionLocal = Depends(get_db)) -> Credentials: +def get_credentials( + user: User, + session: SessionLocal = Depends(get_db), +) -> Credentials: credentials = get_credentials_from_db(user) if credentials is not None: credentials = refresh_token(credentials, session, user) else: credentials = get_credentials_from_consent_screen( - user=user, session=session) + user=user, + session=session, + ) return credentials -def fetch_save_events(credentials: Credentials, user: User, - session: SessionLocal = Depends(get_db)) -> None: +def fetch_save_events( + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> None: if credentials is not None: events = get_current_year_events(credentials, user, session) push_events_to_db(events, user, session) def clean_up_old_credentials_from_db( - session: SessionLocal = Depends(get_db) + session: SessionLocal = Depends(get_db), ) -> None: session.query(OAuthCredentials).filter_by(user_id=None).delete() session.commit() -def get_credentials_from_consent_screen(user: User, - session: SessionLocal = Depends(get_db) - ) -> Credentials: +def get_credentials_from_consent_screen( + user: User, + session: SessionLocal = Depends(get_db), +) -> Credentials: credentials = None if not is_client_secret_none(): # if there is no client_secrets.json flow = InstalledAppFlow.from_client_secrets_file( client_secrets_file=CLIENT_SECRET_FILE, - scopes=SCOPES + scopes=SCOPES, ) - flow.run_local_server(prompt='consent') + flow.run_local_server(prompt="consent") credentials = flow.credentials push_credentials_to_db( - credentials=credentials, user=user, session=session + credentials=credentials, + user=user, + session=session, ) clean_up_old_credentials_from_db(session=session) @@ -65,9 +74,11 @@ def get_credentials_from_consent_screen(user: User, return credentials -def push_credentials_to_db(credentials: Credentials, user: User, - session: SessionLocal = Depends(get_db) - ) -> OAuthCredentials: +def push_credentials_to_db( + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> OAuthCredentials: oauth_credentials = OAuthCredentials( owner=user, @@ -76,7 +87,7 @@ def push_credentials_to_db(credentials: Credentials, user: User, token_uri=credentials.token_uri, client_id=credentials.client_id, client_secret=credentials.client_secret, - expiry=credentials.expiry + expiry=credentials.expiry, ) session.add(oauth_credentials) @@ -89,59 +100,73 @@ def is_client_secret_none() -> bool: def get_current_year_events( - credentials: Credentials, user: User, - session: SessionLocal = Depends(get_db)) -> list: - '''Getting user events from google calendar''' + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> list: + """Getting user events from google calendar""" current_year = datetime.now().year - start = datetime(current_year, 1, 1).isoformat() + 'Z' - end = datetime(current_year + 1, 1, 1).isoformat() + 'Z' - - service = build('calendar', 'v3', credentials=credentials) - events_result = service.events().list( - calendarId='primary', - timeMin=start, - timeMax=end, - singleEvents=True, - orderBy='startTime' - ).execute() - - events = events_result.get('items', []) + start = datetime(current_year, 1, 1).isoformat() + "Z" + end = datetime(current_year + 1, 1, 1).isoformat() + "Z" + + service = build("calendar", "v3", credentials=credentials) + events_result = ( + service.events() + .list( + calendarId="primary", + timeMin=start, + timeMax=end, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) + + events = events_result.get("items", []) return events -def push_events_to_db(events: list, user: User, - session: SessionLocal = Depends(get_db)) -> bool: - '''Adding google events to db''' +def push_events_to_db( + events: list, + user: User, + session: SessionLocal = Depends(get_db), +) -> bool: + """Adding google events to db""" cleanup_user_google_calendar_events(user, session) for event in events: # Running over the events that have come from the API - title = event.get('summary') # The Google event title + title = event.get("summary") # The Google event title # support for all day events - if 'dateTime' in event['start']: + if "dateTime" in event["start"]: # This case handles part time events (not all day events) - start = datetime.fromisoformat(event['start']['dateTime']) - end = datetime.fromisoformat(event['end']['dateTime']) + start = datetime.fromisoformat(event["start"]["dateTime"]) + end = datetime.fromisoformat(event["end"]["dateTime"]) else: # This case handles all day events - start_in_str = event['start']['date'] - start = datetime.strptime(start_in_str, '%Y-%m-%d') + start_in_str = event["start"]["date"] + start = datetime.strptime(start_in_str, "%Y-%m-%d") - end_in_str = event['end']['date'] - end = datetime.strptime(end_in_str, '%Y-%m-%d') + end_in_str = event["end"]["date"] + end = datetime.strptime(end_in_str, "%Y-%m-%d") # if Google Event has a location attached - location = event.get('location') + location = event.get("location") create_google_event(title, start, end, user, location, session) return True -def create_google_event(title: str, start: datetime, - end: datetime, user: User, location: str, - session: SessionLocal = Depends(get_db)) -> Event: +def create_google_event( + title: str, + start: datetime, + end: datetime, + user: User, + location: str, + session: SessionLocal = Depends(get_db), +) -> Event: return create_event( # creating an event obj and pushing it into the db db=session, @@ -150,14 +175,15 @@ def create_google_event(title: str, start: datetime, end=end, owner_id=user.id, location=location, - is_google_event=True + is_google_event=True, ) def cleanup_user_google_calendar_events( - user: User, session: SessionLocal = Depends(get_db) + user: User, + session: SessionLocal = Depends(get_db), ) -> bool: - '''removing all user google events so the next time will be syncronized''' + """removing all user google events so the next time will be syncronized""" for user_event in user.events: user_event_id = user_event.id @@ -171,8 +197,8 @@ def cleanup_user_google_calendar_events( def get_credentials_from_db(user: User) -> Credentials: - '''bring user credential to use with google calendar api - and save the credential in the db''' + """bring user credential to use with google calendar api + and save the credential in the db""" credentials = None @@ -184,15 +210,17 @@ def get_credentials_from_db(user: User) -> Credentials: token_uri=db_credentials.token_uri, client_id=db_credentials.client_id, client_secret=db_credentials.client_secret, - expiry=db_credentials.expiry + expiry=db_credentials.expiry, ) return credentials -def refresh_token(credentials: Credentials, - user: User, session: SessionLocal = Depends(get_db) - ) -> Credentials: +def refresh_token( + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> Credentials: refreshed_credentials = credentials if credentials.expired: @@ -204,7 +232,7 @@ def refresh_token(credentials: Credentials, token_uri=credentials.token_uri, client_id=credentials.client_id, client_secret=credentials.client_secret, - expiry=credentials.expiry + expiry=credentials.expiry, ) session.add(refreshed_credentials) diff --git a/app/internal/import_file.py b/app/internal/import_file.py index 38b83307..fa6b2b48 100644 --- a/app/internal/import_file.py +++ b/app/internal/import_file.py @@ -1,12 +1,19 @@ +import re from collections import defaultdict from datetime import datetime from pathlib import Path -import re from typing import ( - Any, DefaultDict, Dict, Iterator, List, Optional, Tuple, Union + Any, + DefaultDict, + Dict, + Iterator, + List, + Optional, + Tuple, + Union, ) -from icalendar import cal, Calendar +from icalendar import Calendar, cal from loguru import logger from sqlalchemy.orm.session import Session @@ -27,21 +34,32 @@ DATE_FORMAT2 = "%m-%d-%Y %H:%M" DESC_EVENT = "VEVENT" -EVENT_PATTERN = re.compile(r"^(\w{" + str(int(EVENT_HEADER_NOT_EMPTY)) + "," + - str(EVENT_HEADER_LIMIT) + r"}),\s(\w{0," + - str(EVENT_CONTENT_LIMIT) + - r"}),\s(\d{2}-\d{2}-\d{4})," + - r"\s(\d{2}-\d{2}-\d{4})(?:,\s([\w\s-]{0," + - str(LOCATION_LIMIT) + - r"}))?$") +EVENT_PATTERN = re.compile( + r"^(\w{" + + str(int(EVENT_HEADER_NOT_EMPTY)) + + "," + + str(EVENT_HEADER_LIMIT) + + r"}),\s(\w{0," + + str(EVENT_CONTENT_LIMIT) + + r"}),\s(\d{2}-\d{2}-\d{4})," + + r"\s(\d{2}-\d{2}-\d{4})(?:,\s([\w\s-]{0," + + str(LOCATION_LIMIT) + + r"}))?$", +) -EVENT_PATTERN2 = re.compile(r"^(\w{" + str(int(EVENT_HEADER_NOT_EMPTY)) + "," + - str(EVENT_HEADER_LIMIT) + r"}),\s(\w{0," + - str(EVENT_CONTENT_LIMIT) + - r"}),\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})," + - r"\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})" + - r"(?:,\s([\w\s-]{0," + str(LOCATION_LIMIT) + - r"}))?$") +EVENT_PATTERN2 = re.compile( + r"^(\w{" + + str(int(EVENT_HEADER_NOT_EMPTY)) + + "," + + str(EVENT_HEADER_LIMIT) + + r"}),\s(\w{0," + + str(EVENT_CONTENT_LIMIT) + + r"}),\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})," + + r"\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})" + + r"(?:,\s([\w\s-]{0," + + str(LOCATION_LIMIT) + + r"}))?$", +) def import_events(path: str, user_id: int, session: Session) -> bool: @@ -79,8 +97,11 @@ def _is_file_valid_to_import(path: str) -> bool: Returns: True if the file is a valid to be imported, otherwise returns False. """ - return (_is_file_exists(path) and _is_file_extension_valid(path) - and _is_file_size_valid(path)) + return ( + _is_file_exists(path) + and _is_file_extension_valid(path) + and _is_file_size_valid(path) + ) def _is_file_exists(path: str) -> bool: @@ -96,8 +117,8 @@ def _is_file_exists(path: str) -> bool: def _is_file_extension_valid( - path: str, - extension: Union[str, Tuple[str, ...]] = VALID_FILE_EXTENSION, + path: str, + extension: Union[str, Tuple[str, ...]] = VALID_FILE_EXTENSION, ) -> bool: """Whether the path is a valid file extension. @@ -176,29 +197,34 @@ def _is_valid_data_event_ics(component: cal.Event) -> bool: Returns: True if valid, otherwise returns False. """ - return not (str(component.get('summary')) is None - or component.get('dtstart') is None - or component.get('dtend') is None - or not _is_date_in_range(component.get('dtstart').dt) - or not _is_date_in_range(component.get('dtend').dt) - ) + return not ( + str(component.get("summary")) is None + or component.get("dtstart") is None + or component.get("dtend") is None + or not _is_date_in_range(component.get("dtstart").dt) + or not _is_date_in_range(component.get("dtend").dt) + ) def _add_event_component_ics( - component: cal.Event, calendar_content: List[Dict[str, Any]]) -> None: + component: cal.Event, + calendar_content: List[Dict[str, Any]], +) -> None: """Appends event data from an *.ics file. Args: component: An event component. calendar_content: A list of event data. """ - calendar_content.append({ - "Head": str(component.get('summary')), - "Content": str(component.get('description')), - "S_Date": component.get('dtstart').dt.replace(tzinfo=None), - "E_Date": component.get('dtend').dt.replace(tzinfo=None), - "Location": str(component.get('location')), - }) + calendar_content.append( + { + "Head": str(component.get("summary")), + "Content": str(component.get("description")), + "S_Date": component.get("dtstart").dt.replace(tzinfo=None), + "E_Date": component.get("dtend").dt.replace(tzinfo=None), + "Location": str(component.get("location")), + }, + ) def _get_data_from_txt_file(txt_file_path: str) -> List[Dict[str, Any]]: @@ -219,7 +245,9 @@ def _get_data_from_txt_file(txt_file_path: str) -> List[Dict[str, Any]]: event_data = _get_event_data_from_text(event) if not _is_event_dates_valid( - event_data["start_date"], event_data["end_date"]): + event_data["start_date"], + event_data["end_date"], + ): return [] _add_event_component_txt(event_data, calendar_content) @@ -298,15 +326,17 @@ def _is_event_dates_valid(start: str, end: str) -> bool: assert start_date is not None and end_date is not None - is_date_in_range = (_is_date_in_range(start_date) - and _is_date_in_range(end_date)) + is_date_in_range = _is_date_in_range(start_date) and _is_date_in_range( + end_date, + ) is_end_after_start = _is_start_date_before_end_date(start_date, end_date) is_duration_valid = _is_event_duration_valid(start_date, end_date) return is_date_in_range and is_end_after_start and is_duration_valid def _add_event_component_txt( - event: Dict[str, Any], calendar_content: List[Dict[str, Any]] + event: Dict[str, Any], + calendar_content: List[Dict[str, Any]], ) -> None: """Appends event data from a txt file. @@ -321,13 +351,15 @@ def _add_event_component_txt( start_date = datetime.strptime(event["start_date"], DATE_FORMAT) end_date = datetime.strptime(event["end_date"], DATE_FORMAT) - calendar_content.append({ - "Head": event["head"], - "Content": event["content"], - "S_Date": start_date, - "E_Date": end_date, - "Location": event["location"], - }) + calendar_content.append( + { + "Head": event["head"], + "Content": event["content"], + "S_Date": start_date, + "E_Date": end_date, + "Location": event["location"], + }, + ) def _convert_string_to_date(string_date: str) -> Optional[datetime]: @@ -350,7 +382,8 @@ def _convert_string_to_date(string_date: str) -> Optional[datetime]: def _is_date_in_range( - date: datetime, year_range: int = EVENT_VALID_YEARS + date: datetime, + year_range: int = EVENT_VALID_YEARS, ) -> bool: """Whether the date is in range. @@ -385,7 +418,9 @@ def _is_start_date_before_end_date(start: datetime, end: datetime) -> bool: def _is_event_duration_valid( - start: datetime, end: datetime, max_days: int = EVENT_DURATION_LIMIT + start: datetime, + end: datetime, + max_days: int = EVENT_DURATION_LIMIT, ) -> bool: """Whether an event duration is valid. @@ -402,8 +437,8 @@ def _is_event_duration_valid( def _is_file_valid_to_save_to_database( - events: List[Dict[str, Any]], - max_event_start_date: int = MAX_EVENTS_START_DATE, + events: List[Dict[str, Any]], + max_event_start_date: int = MAX_EVENTS_START_DATE, ) -> bool: """Whether the number of events starting on the same date is valid. @@ -430,7 +465,9 @@ def _is_file_valid_to_save_to_database( def _save_events_to_database( - events: List[Dict[str, Any]], user_id: int, session: Session + events: List[Dict[str, Any]], + user_id: int, + session: Session, ) -> None: """Inserts the events into the Event table. @@ -446,11 +483,12 @@ def _save_events_to_database( end = event["E_Date"] location = event["Location"] owner_id = user_id - create_event(db=session, - title=title, - content=content, - start=start, - end=end, - location=location, - owner_id=owner_id, - ) + create_event( + db=session, + title=title, + content=content, + start=start, + end=end, + location=location, + owner_id=owner_id, + ) diff --git a/app/internal/import_holidays.py b/app/internal/import_holidays.py index 6f5f6f0c..66266f30 100644 --- a/app/internal/import_holidays.py +++ b/app/internal/import_holidays.py @@ -1,13 +1,15 @@ import re from datetime import datetime, timedelta +from typing import List, Match -from app.database.models import User, Event, UserEvent from sqlalchemy.orm import Session -from typing import List, Match + +from app.database.models import Event, User, UserEvent REGEX_EXTRACT_HOLIDAYS = re.compile( - r'SUMMARY:(?P.*)(\n.*){1,8}DTSTAMP:(?P<date>\w{8})', - re.MULTILINE) + r"SUMMARY:(?P<title>.*)(\n.*){1,8}DTSTAMP:(?P<date>\w{8})", + re.MULTILINE, +) def get_holidays_from_file(file: List[Event], session: Session) -> List[Event]: @@ -22,24 +24,27 @@ def get_holidays_from_file(file: List[Event], session: Session) -> List[Event]: holidays = [] for holiday in parsed_holidays: holiday_event = create_holiday_event( - holiday, session.query(User).filter_by(id=1).first().id) + holiday, + session.query(User).filter_by(id=1).first().id, + ) holidays.append(holiday_event) return holidays def create_holiday_event(holiday: Match[str], owner_id: int) -> Event: valid_ascii_chars_range = 128 - title = holiday.groupdict()['title'].strip() - title_to_save = ''.join(i if ord(i) < valid_ascii_chars_range - else '' for i in title) - date = holiday.groupdict()['date'].strip() - format_string = '%Y%m%d' + title = holiday.groupdict()["title"].strip() + title_to_save = "".join( + i if ord(i) < valid_ascii_chars_range else "" for i in title + ) + date = holiday.groupdict()["date"].strip() + format_string = "%Y%m%d" holiday = Event( title=title_to_save, start=datetime.strptime(date, format_string), end=datetime.strptime(date, format_string) + timedelta(days=1), - content='holiday', - owner_id=owner_id + content="holiday", + owner_id=owner_id, ) return holiday @@ -55,10 +60,7 @@ def save_holidays_to_db(holidays: List[Event], session: Session): session.flush(holidays) userevents = [] for holiday in holidays: - userevent = UserEvent( - user_id=holiday.owner_id, - event_id=holiday.id - ) + userevent = UserEvent(user_id=holiday.owner_id, event_id=holiday.id) userevents.append(userevent) session.add_all(userevents) session.commit() diff --git a/app/internal/international_days.py b/app/internal/international_days.py new file mode 100644 index 00000000..618ea008 --- /dev/null +++ b/app/internal/international_days.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Dict, Optional, Union + +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import func + +from app.database.models import InternationalDays + + +def get_international_day( + international_day: Dict[str, Union[str, int]], +) -> InternationalDays: + """Returns an international day object from the dictionary data. + + Args: + international_day: A dictionary international day + related information. + + Returns: + A new international day object. + """ + return InternationalDays( + day=international_day["day"], + month=international_day["month"], + international_day=international_day["international_day"], + ) + + +def get_international_day_per_day( + session: Session, + date: datetime, +) -> Optional[InternationalDays]: + day_num = date.day + month = date.month + international_day = ( + session.query(InternationalDays) + .filter(InternationalDays.day == day_num) + .filter(InternationalDays.month == month) + .order_by(func.random()) + .first() + ) + return international_day diff --git a/app/internal/jokes.py b/app/internal/jokes.py new file mode 100644 index 00000000..d8de9456 --- /dev/null +++ b/app/internal/jokes.py @@ -0,0 +1,24 @@ +from typing import Dict, Optional + +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import func + +from app.database.models import Joke + + +def get_joke(joke_: Dict[str, Optional[str]]) -> Joke: + """Returns a Joke object from the dictionary data. + + Args: + joke_: A dictionary joke related information. + + Returns: + A new Joke object. + """ + return Joke( + text=joke_['text'], + ) + + +def get_a_joke(session: Session): + return session.query(Joke).order_by(func.random()).first() diff --git a/app/internal/json_data_loader.py b/app/internal/json_data_loader.py index d67443e6..779bfa17 100644 --- a/app/internal/json_data_loader.py +++ b/app/internal/json_data_loader.py @@ -5,8 +5,8 @@ from loguru import logger from sqlalchemy.orm import Session -from app.database.models import Base, Quote, Zodiac -from app.internal import daily_quotes, zodiac +from app.database.models import Base, InternationalDays, Joke, Quote, Zodiac +from app.internal import daily_quotes, international_days, jokes, zodiac def load_to_database(session: Session) -> None: @@ -35,6 +35,20 @@ def load_to_database(session: Session) -> None: daily_quotes.get_quote, ) + _insert_into_database( + session, + 'app/resources/international_days.json', + InternationalDays, + international_days.get_international_day, + ) + + _insert_into_database( + session, + 'app/resources/jokes.json', + Joke, + jokes.get_joke, + ) + def _insert_into_database( session: Session, diff --git a/app/internal/logger_customizer.py b/app/internal/logger_customizer.py index ddc06c0c..01026326 100644 --- a/app/internal/logger_customizer.py +++ b/app/internal/logger_customizer.py @@ -1,7 +1,8 @@ -from pathlib import Path import sys +from pathlib import Path -from loguru import _Logger as Logger, logger +from loguru import _Logger as Logger +from loguru import logger class LoggerConfigError(Exception): @@ -9,14 +10,16 @@ class LoggerConfigError(Exception): class LoggerCustomizer: - @classmethod - def make_logger(cls, log_path: Path, - log_filename: str, - log_level: str, - log_rotation_interval: str, - log_retention_interval: str, - log_format: str) -> Logger: + def make_logger( + cls, + log_path: Path, + log_filename: str, + log_level: str, + log_rotation_interval: str, + log_retention_interval: str, + log_format: str, + ) -> Logger: """Creates a logger from given configurations Args: @@ -42,23 +45,25 @@ def make_logger(cls, log_path: Path, level=log_level, retention=log_retention_interval, rotation=log_rotation_interval, - format=log_format + format=log_format, ) except (TypeError, ValueError) as err: raise LoggerConfigError( f"You have an issue with the logger configuration: {err!r}, " - "fix it please") + "fix it please", + ) return logger @classmethod - def customize_logging(cls, - file_path: Path, - level: str, - rotation: str, - retention: str, - format: str - ) -> Logger: + def customize_logging( + cls, + file_path: Path, + level: str, + rotation: str, + retention: str, + format: str, + ) -> Logger: """Used to customize the logger instance Args: @@ -79,7 +84,7 @@ def customize_logging(cls, enqueue=True, backtrace=True, level=level.upper(), - format=format + format=format, ) logger.add( str(file_path), @@ -88,7 +93,7 @@ def customize_logging(cls, enqueue=True, backtrace=True, level=level.upper(), - format=format + format=format, ) return logger diff --git a/app/internal/meds.py b/app/internal/meds.py new file mode 100644 index 00000000..b4461b58 --- /dev/null +++ b/app/internal/meds.py @@ -0,0 +1,446 @@ +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from pydantic.main import BaseModel +from sqlalchemy.orm.session import Session + +from app.database.models import Event +from app.internal.utils import create_model, get_time_from_string + +MAX_EVENT_QUANTITY = 50 + +ERRORS = { + "finish": "Finish Date must must be later than or equal to Start Date", + "max": "Maximal Interval must must be larger than or equal to Minimal \ + Interval", + "amount": "Interval between Earliest Reminder and Latest Reminder not \ + long enough for Daily Amount with Minimal Interval", + "quantity": "Total number of reminders can't be larger than " + + f"{MAX_EVENT_QUANTITY}. Please lower the daily amount, or " + + "choose a shorter time period.", +} + + +class Form(BaseModel): + """Represents a translated form object. + + name (str, optional) - Medication name. + first (time, optional) - First dose time, if given. + amount (int) - Daily dosage. + early (time) - Earliest reminder time. + late (time) - Latest reminder time. + min (time) - Minimal interval between reminders. + max (time) - Maximal interval between reminders. + start (datetime) - First reminder date and time. + end (datetime) - Last reminder date and time. + note (str, optional) - Additional description. + """ + + name: Optional[str] + first: Optional[time] + amount: int + early: time + late: time + min: time + max: time + start: datetime + end: datetime + note: Optional[str] + + +def adjust_day( + datetime_obj: datetime, + early: time, + late: time, + eq: bool = False, +) -> datetime: + """Returns datetime_obj as same or following day as needed. + + Args: + datetime_obj (datetime): Datetime object to adjust. + early (time): Earlir time object. + late (time): Later time object. + eq (bool): Apply time object comparison. + + Returns: + datetime: Datetime_obj with adjusted date. + """ + if late < early or eq and late == early: + datetime_obj += timedelta(days=1) + return datetime_obj + + +def trans_form(web_form: Dict[str, str]) -> Tuple[Form, Dict[str, Any]]: + """Converts all form data to useable types and return as a Tuple. + + Args: + form (dict(str, str)): Form to translate. + + Returns: + tuple(Form, dict(str, any)): Tuple consisting of: + - Form object with all converted form data. + - Dictionary version of Form object. + """ + form = {} + form["name"] = web_form["name"] + start_date = get_time_from_string(web_form["start"]) + form["first"] = get_time_from_string(web_form["first"]) + end_date = get_time_from_string(web_form["end"]) + form["amount"] = int(web_form["amount"]) + form["early"] = get_time_from_string(web_form["early"]) + form["late"] = get_time_from_string(web_form["late"]) + form["min"] = get_time_from_string(web_form["min"]) + form["max"] = get_time_from_string(web_form["max"]) + first_time = form["first"] if form["first"] else form["early"] + form["start"] = datetime.combine(start_date, first_time) + end_date = adjust_day( + end_date, + web_form["early"], + web_form["late"], + eq=True, + ) + form["end"] = datetime.combine(end_date, form["late"]) + form["note"] = web_form["note"] + + form_obj = Form(**form) + form["start"] = form["start"].date() + form["end"] = form["end"].date() + return form_obj, form + + +def convert_time_to_minutes(time_obj: time) -> int: + """Returns time object as minutes. + + Args: + time_obj (time): Time object to convert to minutes. + + Returns: + int: Total minutes in time object. + """ + return round(time_obj.hour * 60 + time_obj.minute) + + +def get_interval_in_minutes(early: time, late: time) -> int: + """Returns interval between 2 time objects in minutes. + + Args: + early (time): Earlier time object. + late (time): Later time object. Interpreted as following day if earlier + than early. + + Returns: + int: Interval between time objects in minutes. + """ + if early == late: + return 0 + extra = int(early > late) + early_date = datetime.combine(datetime.min, early) + late_date = datetime.combine(datetime.min + timedelta(extra), late) + interval = late_date - early_date + return round(interval.seconds / 60) + + +def validate_amount(amount: int, min: time, early: time, late: time) -> bool: + """Returns True if interval is sufficient for reminder amount with minimal + interval constraint, False otherwise + + Args: + amount (int): Reminder amount. + min (time): Minimal interval between reminders. + early (time) - Earliest reminder time. + late (time) - Latest reminder time. + + Returns: + bool: True if interval is sufficient for reminder amount with minimal + interval constraint, False otherwise + """ + min_time = (amount - 1) * convert_time_to_minutes(min) + interval = get_interval_in_minutes(early, late) + return min_time <= interval + + +def validate_events(datetimes: Iterator[datetime]) -> bool: + """Return True if total amount of reminders is less than max amount, False + otherwise. + + Args: + datetimes (list(datetime)): Reminder times. + + Returns: + bool: True if total amount of reminders is less than amx amount, False + otherwise. + """ + return len(list(datetimes)) <= MAX_EVENT_QUANTITY + + +def validate_form(form: Form) -> List[str]: + """Returns a list of error messages for given form. + + Args: + form (Form): Form object to validate. + + Returns: + list(str): Error messages for given form. + """ + errors = [] + if form.end < form.start: + errors.append(ERRORS["finish"]) + if form.max < form.min: + errors.append(ERRORS["max"]) + if not validate_amount(form.amount, form.min, form.early, form.late): + errors.append(ERRORS["amount"]) + datetimes = get_reminder_datetimes(form) + if not validate_events(datetimes): + errors.append(ERRORS["quantity"]) + + return errors + + +def calc_reminder_interval_in_seconds(form: Form) -> int: + """Returns interval between reminders in seconds. + + Args: + form (Form): Form object containing all relevant data. + + Returns: + int: Interval between reminders in seconds. + """ + if form.amount == 1: + return 0 + reminder_interval = get_interval_in_minutes(form.early, form.late) + max_med_interval = reminder_interval / (form.amount - 1) + min_minutes = convert_time_to_minutes(form.min) + max_minutes = convert_time_to_minutes(form.max) + avg_med_interval = (min_minutes + max_minutes) / 2 + return int(min(max_med_interval, avg_med_interval) * 60) + + +def get_reminder_times(form: Form) -> List[time]: + """Returns a list of time objects for reminders based on form data. + + Args: + form (Form): Form object containing all relevant data. + + Returns: + list(time): Time objects for reminders. + """ + interval = calc_reminder_interval_in_seconds(form) + times = [] + early_reminder = datetime.combine(datetime.min, form.early) + for i in range(form.amount): + reminder = early_reminder + timedelta(seconds=interval) * i + times.append(reminder.time()) + + wasted_time = get_interval_in_minutes(times[-1], form.late) / 2 + times = [ + ( + datetime.combine(datetime.min, time_obj) + + timedelta(minutes=wasted_time) + ).time() + for time_obj in times + ] + + return times + + +def validate_datetime( + reminder: datetime, + day: date, + early: time, + late: time, +) -> bool: + """Returns True if reminder is between earlist and latest reminder times on + a given date or equal to any of them, False otherwise. + + Args: + reminder (datetime): Datetime object to validate. + day (date): Date for earlist reminder. + early (time): Earliest reminder time. + late (late): Latest reminder time. Interpreted as following day if + earlier than early. + + Returns: + bool: True if reminder is between earlist and latest reminder times on + a given date or equal to any of them, False otherwise. + """ + early_datetime = datetime.combine(day, early) + late_datetime = datetime.combine(day, late) + late_datetime = adjust_day(late_datetime, early, late) + return early_datetime <= reminder <= late_datetime + + +def validate_first_day_reminder( + previous: datetime, + reminder_time: time, + min: time, + max: time, +) -> bool: + """Returns True if interval between reminders is valid, false otherwise. + + Args: + previous (datetime): Previous reminder. + reminder_time (time): New reminder time. + min (time) - Minimal interval between reminders. + max (time) - Maximal interval between reminders. + + Returns: + bool: True if interval between reminders is valid, false otherwise. + """ + interval = get_interval_in_minutes(previous.time(), reminder_time) + min_minutes = convert_time_to_minutes(min) + max_minutes = convert_time_to_minutes(max) + return max_minutes >= interval >= min_minutes + + +def get_different_time_reminder( + previous: datetime, + min: time, + early: time, + late: time, +) -> Optional[datetime]: + """Returns datetime object for first day reminder with non-standard time. + + Args: + previous (datetime): Previous reminder. + min (time) - Minimal interval between reminders. + early (time): Earliest reminder time. + late (late): Latest reminder time. Interpreted as following day if + earlier than early. + + Returns: + datetime | None: First day reminder with non-standard time, if valid. + """ + reminder = previous + timedelta(minutes=convert_time_to_minutes(min)) + if validate_datetime(reminder, previous.date(), early, late): + return reminder + + +def create_first_day_reminder( + form: Form, + reminder_time: time, + previous: datetime, +) -> Optional[datetime]: + """Returns datetime object for reminder on first day. + + form (Form): Form object containing all relevant data. + reminder_time (time): Time object for new reminder. + previous (datetime): Previous reminder. + + Returns: + datetime | None: First day reminder. + """ + reminder = datetime.combine(form.start.date(), reminder_time) + reminder = adjust_day(reminder, form.early, reminder_time) + if reminder > form.start: + if not validate_first_day_reminder( + previous, + reminder_time, + form.min, + form.max, + ): + reminder = get_different_time_reminder( + previous, + form.min, + form.early, + form.late, + ) + return reminder + + +def get_first_day_reminders( + form: Form, + times: List[time], +) -> Iterator[datetime]: + """Generates datetime objects for reminders on the first day. + + Args: + form (Form): Form object containing all relevant data. + times (list(time)): Time objects for reminders. + + Yields: + datetime: First day reminder datetime object. + """ + yield form.start + previous = form.start + i = 1 + for reminder_time in times: + if i <= form.amount: + new = create_first_day_reminder(form, reminder_time, previous) + if new: + yield new + previous = new + i += 1 + + +def reminder_generator( + times: List[time], + early: time, + start: datetime, + day: date, + end: datetime, +) -> Iterator[datetime]: + """Generates datetime objects for reminders based on times and date. + + Args: + times (list(time)): Reminder times. + early (time): Earliest reminder time. + start (datetime): First reminder date and time. + day (date): Reminders date. + end (datetime) - Last reminder date and time. + + Yields: + datetime: Reminder datetime object. + """ + for time_obj in times: + extra = int(time_obj < early) + day_date = start.date() + timedelta(day + extra) + reminder = datetime.combine(day_date, time_obj) + if reminder <= end: + yield reminder + + +def get_reminder_datetimes(form: Form) -> Iterator[datetime]: + """Generates datetime object for reminders. + + Args: + form (Form): Form object containing all relevant data. + + Yields: + datetime: Reminder datetime object. + """ + times = get_reminder_times(form) + total_days = (form.end.date() - form.start.date()).days + 1 + for day in range(total_days): + if day == 0 and form.first: + yield from get_first_day_reminders(form, times) + else: + yield from reminder_generator( + times, + form.early, + form.start, + day, + form.end, + ) + + +def create_events(session: Session, user_id: int, form: Form) -> None: + """Creates reminder events in the DB based on form data. + + Args: + session (Session): DB session. + user_id (int): ID of user to create events for. + form (Form): Form object containing all relevant data. + """ + title = "It's time to take your meds" + if form.name: + title = f"{form.name.title()} - {title}" + datetimes = get_reminder_datetimes(form) + for event_time in datetimes: + event_data = { + "title": title, + "start": event_time, + "end": event_time + timedelta(minutes=5), + "content": form.note, + "owner_id": user_id, + } + create_model(session, Event, **event_data) diff --git a/app/internal/notes/__init__.py b/app/internal/notes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/internal/notes/notes.py b/app/internal/notes/notes.py new file mode 100644 index 00000000..5a8e999f --- /dev/null +++ b/app/internal/notes/notes.py @@ -0,0 +1,69 @@ +from datetime import datetime +from typing import Any, Dict, List + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.database.models import Note +from app.database.schemas import NoteSchema + + +async def create(session: Session, payload: NoteSchema) -> int: + note = Note( + title=payload.title, + description=payload.description, + creator=payload.creator, + ) + session.add(note) + session.commit() + session.refresh(note) + return note.id + + +async def view(session: Session, note_id: int) -> Note: + note = session.query(Note).filter_by(id=note_id).first() + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Note with id {note_id} not found", + ) + return note + + +async def get_all( + session: Session, + skip: int = 0, + limit: int = 100, +) -> List[Note]: + return session.query(Note).offset(skip).limit(limit).all() + + +async def update(request: NoteSchema, session: Session, note_id: int) -> str: + note = session.query(Note).filter_by(id=note_id) + if not note.first(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Note with id {note_id} not found", + ) + if request.timestamp is None: + request.timestamp = datetime.utcnow() + note.update(request.dict(exclude_unset=True), synchronize_session=False) + session.commit() + return "updated" + + +async def delete(session: Session, note_id: int) -> str: + note = session.query(Note).filter_by(id=note_id) + if not note.first(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Note with id {note_id} not found", + ) + note.delete(synchronize_session=False) + session.commit() + return "deleted" + + +async def create_note(note: NoteSchema, session: Session) -> Dict[str, Any]: + note_id = await create(session, note) + return {"id": note_id, **dict(note)} diff --git a/app/internal/notification.py b/app/internal/notification.py new file mode 100644 index 00000000..bc638e85 --- /dev/null +++ b/app/internal/notification.py @@ -0,0 +1,175 @@ +from operator import attrgetter +from typing import Callable, Iterator, List, Union + +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, + InvitationStatusEnum, + Message, + 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. + 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() diff --git a/app/internal/on_this_day_events.py b/app/internal/on_this_day_events.py index 3a058df1..c43cd0b6 100644 --- a/app/internal/on_this_day_events.py +++ b/app/internal/on_this_day_events.py @@ -1,10 +1,10 @@ -from datetime import date, datetime import json +from datetime import date, datetime from typing import Any, Dict +import requests from fastapi import Depends from loguru import logger -import requests from sqlalchemy import func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -14,36 +14,35 @@ from app.dependencies import get_db -def insert_on_this_day_data( - db: Session = Depends(get_db) -) -> Dict[str, Any]: +def insert_on_this_day_data(db: Session = Depends(get_db)) -> Dict[str, Any]: now = datetime.now() day, month = now.day, now.month res = requests.get( - f'https://byabbe.se/on-this-day/{month}/{day}/events.json') + f"https://byabbe.se/on-this-day/{month}/{day}/events.json", + ) text = json.loads(res.text) - res_events = text.get('events') - res_date = text.get('date') - res_wiki = text.get('wikipedia') - db.add(WikipediaEvents(events=res_events, - date_=res_date, wikipedia=res_wiki)) + res_events = text.get("events") + res_date = text.get("date") + res_wiki = text.get("wikipedia") + db.add( + WikipediaEvents(events=res_events, date_=res_date, wikipedia=res_wiki), + ) db.commit() return text -def get_on_this_day_events( - db: Session = Depends(get_db) -) -> Dict[str, Any]: +def get_on_this_day_events(db: Session = Depends(get_db)) -> Dict[str, Any]: try: - data = (db.query(WikipediaEvents). - filter( - func.date(WikipediaEvents.date_inserted) == date.today()). - one()) + data = ( + db.query(WikipediaEvents) + .filter(func.date(WikipediaEvents.date_inserted) == date.today()) + .one() + ) except NoResultFound: data = insert_on_this_day_data(db) except (SQLAlchemyError, AttributeError) as e: - logger.error(f'on this day failed with error: {e}') - data = {'events': [], 'wikipedia': 'https://en.wikipedia.org/'} + logger.exception(f"on this day failed with error: {e}") + data = {"events": [], "wikipedia": "https://en.wikipedia.org/"} return data diff --git a/app/internal/privacy.py b/app/internal/privacy.py new file mode 100644 index 00000000..c90fbf87 --- /dev/null +++ b/app/internal/privacy.py @@ -0,0 +1,7 @@ +import enum + + +class PrivacyKinds(enum.Enum): + Public = 1 + Private = 2 + Hidden = 3 diff --git a/app/internal/security/dependancies.py b/app/internal/security/dependancies.py deleted file mode 100644 index 7f2a0795..00000000 --- a/app/internal/security/dependancies.py +++ /dev/null @@ -1,25 +0,0 @@ -from starlette.requests import Request - -from app.dependencies import get_db -from app.internal.security.ouath2 import ( - Depends, Session, check_jwt_token, get_authorization_cookie) - - -async def is_logged_in( - request: Request, db: Session = Depends(get_db), - jwt: str = Depends(get_authorization_cookie)) -> bool: - """ - A dependency function protecting routes for only logged in user - """ - await check_jwt_token(db, jwt) - return True - - -async def is_manager( - request: Request, db: Session = Depends(get_db), - jwt: str = Depends(get_authorization_cookie)) -> bool: - """ - A dependency function protecting routes for only logged in manager - """ - await check_jwt_token(db, jwt, manager=True) - return True diff --git a/app/internal/security/dependencies.py b/app/internal/security/dependencies.py new file mode 100644 index 00000000..19db881b --- /dev/null +++ b/app/internal/security/dependencies.py @@ -0,0 +1,112 @@ +from typing import Optional + +from fastapi import Depends, HTTPException +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED + +from app.database.models import User +from app.dependencies import get_db +from app.internal.security import schema +from app.internal.security.ouath2 import ( + Session, + get_authorization_cookie, + get_jwt_token, +) + + +async def is_logged_in( + request: Request, + db: Session = Depends(get_db), + jwt: str = Depends(get_authorization_cookie), +) -> bool: + """ + A dependency function protecting routes for only logged in user + """ + jwt_payload = get_jwt_token(jwt) + user_id = jwt_payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Your token is not valid. Please log in again", + ) + return True + + +async def is_manager( + request: Request, + db: Session = Depends(get_db), + jwt: str = Depends(get_authorization_cookie), +) -> bool: + """ + A dependency function protecting routes for only logged in manager + """ + jwt_payload = get_jwt_token(jwt) + user_id = jwt_payload.get("user_id") + if jwt_payload.get("is_manager") and user_id: + return True + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + headers=request.url.path, + detail="You don't have a permition to enter this page", + ) + + +async def current_user_from_db( + request: Request, + db: Session = Depends(get_db), + jwt: str = Depends(get_authorization_cookie), +) -> User: + """ + Returns logged in User object. + A dependency function protecting routes for only logged in user. + """ + jwt_payload = get_jwt_token(jwt) + username = jwt_payload.get("sub") + user_id = jwt_payload.get("user_id") + db_user = await User.get_by_username(db, username=username) + if db_user and db_user.id == user_id: + return db_user + else: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + headers=request.url.path, + detail="Your token is incorrect. Please log in again", + ) + + +async def current_user( + request: Request, + db: Session = Depends(get_db), + jwt: str = Depends(get_authorization_cookie), +) -> schema: + """ + Returns logged in User object. + A dependency function protecting routes for only logged in user. + """ + jwt_payload = get_jwt_token(jwt) + username = jwt_payload.get("sub") + user_id = jwt_payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Your token is not valid. Please log in again", + ) + return schema.CurrentUser(user_id=user_id, username=username) + + +def get_jinja_current_user(request: Request) -> Optional[schema.CurrentUser]: + """Return the currently logged in user. + Returns logged in User object if exists, None if not. + Set as a jinja global parameter. + """ + if "Authorization" not in request.cookies: + return None + jwt_payload = get_jwt_token(request.cookies["Authorization"]) + username = jwt_payload.get("sub") + user_id = jwt_payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Your token is not valid. Please log in again", + ) + return schema.CurrentUser(user_id=user_id, username=username) diff --git a/app/internal/security/ouath2.py b/app/internal/security/ouath2.py index fd96973f..a20f3b0e 100644 --- a/app/internal/security/ouath2.py +++ b/app/internal/security/ouath2.py @@ -1,26 +1,39 @@ from datetime import datetime, timedelta from typing import Union -from passlib.context import CryptContext +import jwt from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer -import jwt from jwt.exceptions import InvalidSignatureError +from passlib.context import CryptContext from sqlalchemy.orm import Session from starlette.requests import Request from starlette.responses import RedirectResponse -from starlette.status import HTTP_401_UNAUTHORIZED -from . import schema +from starlette.status import HTTP_302_FOUND, HTTP_401_UNAUTHORIZED from app.config import JWT_ALGORITHM, JWT_KEY, JWT_MIN_EXP from app.database.models import User +from . import schema pwd_context = CryptContext(schemes=["bcrypt"]) oauth_schema = OAuth2PasswordBearer(tokenUrl="/login") -def get_hashed_password(password: bytes) -> str: +async def update_password( + db: Session, + username: str, + user_password: str, +) -> None: + """Updating User password in database""" + db_user = await User.get_by_username(db=db, username=username) + hashed_password = get_hashed_password(user_password) + db_user.password = hashed_password + db.commit() + return + + +def get_hashed_password(password: str) -> str: """Hashing user password""" return pwd_context.hash(password) @@ -30,17 +43,47 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) +async def is_email_compatible_to_username( + db: Session, + user: schema.ForgotPassword, + email: bool = False, +) -> Union[schema.ForgotPassword, bool]: + """ + Verifying database record by username. + Comparing given email to database record, + """ + db_user = await User.get_by_username( + db=db, + username=user.username.lstrip("@"), + ) + if not db_user: + return False + if db_user.email == user.email: + return schema.ForgotPassword( + username=user.username, + user_id=db_user.id, + email=db_user.email, + ) + return False + + async def authenticate_user( db: Session, - new_user: schema.LoginUser, + user: schema.LoginUser, ) -> Union[schema.LoginUser, bool]: - """Verifying user is in database and password is correct""" - db_user = await User.get_by_username(db=db, username=new_user.username) - if db_user and verify_password(new_user.password, db_user.password): + """ + Verifying database record by username. + Comparing given password to database record, + varies with which function called this action. + """ + db_user = await User.get_by_username(db=db, username=user.username) + if not db_user: + return False + elif verify_password(user.password, db_user.password): return schema.LoginUser( user_id=db_user.id, is_manager=db_user.is_manager, - username=new_user.username, + username=user.username, password=db_user.password, ) return False @@ -63,11 +106,9 @@ def create_jwt_token( return jwt_token -async def check_jwt_token( - db: Session, +def get_jwt_token( token: str = Depends(oauth_schema), - path: bool = None, - manager: bool = False, + path: Union[bool, str] = None, ) -> User: """ Check whether JWT token is correct. @@ -76,15 +117,6 @@ async def check_jwt_token( """ try: jwt_payload = jwt.decode(token, JWT_KEY, algorithms=JWT_ALGORITHM) - if not manager: - return True - if jwt_payload.get("is_manager"): - return True - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - headers=path, - detail="You don't have a permition to enter this page", - ) except InvalidSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, @@ -103,6 +135,7 @@ async def check_jwt_token( headers=path, detail="Your token is incorrect. Please log in again", ) + return jwt_payload async def get_authorization_cookie(request: Request) -> str: @@ -130,6 +163,6 @@ async def auth_exception_handler( """ paramas = f"?next={exc.headers}&message={exc.detail}" url = f"/login{paramas}" - response = RedirectResponse(url=url) + response = RedirectResponse(url=url, status_code=HTTP_302_FOUND) response.delete_cookie("Authorization") return response diff --git a/app/internal/security/schema.py b/app/internal/security/schema.py index 31a009e7..2e5fee8d 100644 --- a/app/internal/security/schema.py +++ b/app/internal/security/schema.py @@ -1,17 +1,85 @@ -from typing import Optional +from typing import Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, validator -class LoginUser(BaseModel): +class CurrentUser(BaseModel): """ Validating fields types - Returns a User object for signing in. + Returns a user details as a class. """ + user_id: Optional[int] + username: str + + class Config: + orm_mode = True + + +class LoginUser(CurrentUser): + """ + Validating fields types + Returns a User object for signing in. + """ + is_manager: Optional[bool] + password: str + + +class ForgotPassword(BaseModel): + """ + BaseModel for collecting and verifying user + details sending a token via email + """ + + username: str + email: str + user_id: Optional[str] = None + email_verification_token: Optional[str] = None + is_manager: Optional[bool] = False + + class Config: + orm_mode = True + + @validator("username") + def password_length(cls, username: str) -> Union[ValueError, str]: + """Validating username length is legal""" + if not (MIN_FIELD_LENGTH < len(username) < MAX_FIELD_LENGTH): + raise ValueError + return username + + +MIN_FIELD_LENGTH = 3 +MAX_FIELD_LENGTH = 20 + + +class ResetPassword(BaseModel): + """ + Validating fields types + """ + username: str password: str + confirm_password: str class Config: orm_mode = True + fields = {"confirm_password": "confirm-password"} + + @validator("confirm_password") + def passwords_match( + cls, + confirm_password: str, + values: BaseModel, + ) -> Union[ValueError, str]: + """Validating passwords fields identical.""" + if "password" in values and confirm_password != values["password"]: + raise ValueError + return confirm_password + + @validator("password") + def password_length(cls, password: str) -> Union[ValueError, str]: + """Validating password length is legal""" + if not (MIN_FIELD_LENGTH < len(password) < MAX_FIELD_LENGTH): + raise ValueError + return password diff --git a/app/internal/showevent.py b/app/internal/showevent.py new file mode 100644 index 00000000..a89b9db8 --- /dev/null +++ b/app/internal/showevent.py @@ -0,0 +1,17 @@ +from datetime import datetime +from typing import List + +from sqlalchemy.orm import Session + +from app.database.models import Event, UserEvent + + +def get_upcoming_events(session: Session, user_id: int) -> List[Event]: + upcoming_events = ( + session.query(Event) + .join(UserEvent) + .filter(UserEvent.user_id == user_id) + .filter(Event.start >= datetime.now()) + .order_by(Event.start) + ) + return upcoming_events diff --git a/app/internal/statistics.py b/app/internal/statistics.py new file mode 100644 index 00000000..cc71a343 --- /dev/null +++ b/app/internal/statistics.py @@ -0,0 +1,527 @@ +import datetime +from typing import Dict, List, NamedTuple, Tuple, Union + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session +from sqlalchemy.util import symbol + +from app.database.models import Event, UserEvent +from app.internal.user import user + +SUCCESS_STATUS = True +ERROR_STATUS = False +INVALID_DATE_RANGE = "End date must be later than start date" +INVALID_USER = "Invalid user id" +NIN_IN_DAY = 1440 + +ValidationResult = NamedTuple( + "ValidationResult", + [ + ("valid_input", bool), + ("error_text", str), + ("start", datetime.datetime), + ("end", datetime.datetime), + ], +) +DailyEventsStatistics = NamedTuple( + "DailyEventsStatistics", + [ + ("min_events_in_day", int), + ("max_events_in_day", int), + ("avg_events_per_day", float), + ], +) +EventsDurationStatistics = NamedTuple( + "EventsDurationStatistics", + [ + ("shortest_event", float), + ("longest_event", float), + ("average_event", float), + ], +) + + +def validate_input( + db: Session, + userid: int, + start: datetime.datetime = None, + end: datetime.datetime = None, +) -> ValidationResult: + """1. input validations: + valid userid. + end date > start date. + 2. date range preparation: + start: will be at 00:00 on input date (today if None). + end: will be at 23:59:59 on input date (today if None). + All statistics are based on full days (00:00:00-23:59:59). + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: NamedTuple with the following data: + valid_input: boolean stating if input values are valid. + error_text: str (relevant ib case of error). + start: start of date range. + end: end of date range. + """ + if not user.does_user_exist(session=db, user_id=userid): + return ValidationResult( + valid_input=False, + error_text=INVALID_USER, + start=start, + end=end, + ) + date = start or datetime.datetime.now() + start = datetime.datetime(date.year, date.month, date.day) + single_day = datetime.timedelta(days=1, seconds=-1) + date = end or start + end = datetime.datetime(date.year, date.month, date.day) + single_day + if start >= end: + return ValidationResult( + valid_input=False, + error_text=INVALID_DATE_RANGE, + start=start, + end=end, + ) + return ValidationResult( + valid_input=True, + error_text="", + start=start, + end=end, + ) + + +def get_date_filter_between_dates( + start: datetime.datetime, + end: datetime.datetime, +) -> symbol: + """ + Prepare the filter by dates using declarative SQLAlchemy. + + Returns: + concatenated sql part for events date as sqlalchemy.util.symbol + """ + event_start, event_end = func.date(Event.start), func.date(Event.end) + return or_( + and_(event_start >= start, event_start <= end), + and_(event_end >= start, event_end <= end), + and_(event_start <= start, event_end >= end), + ) + + +def get_events_count_stats( + db: Session, + userid: int, + start: datetime.datetime, + end: datetime.datetime, +) -> Dict[str, Dict[str, int]]: + """calculate statistics and events relevant for the requested user + and the requested date range. + all logic is performed in the db. + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: json with + events_count_stats: (for requested date range). + meetings_for_user: events that the user has. + created_by_user: events that the user has created. + """ + user_to_event = (UserEvent, UserEvent.event_id == Event.id) + by_user_id = UserEvent.user_id == userid + by_owner_id = Event.owner_id == userid + meetings_for_user = ( + db.query(Event.id) + .join(user_to_event) + .filter(by_user_id) + .filter(get_date_filter_between_dates(start, end)) + .count() + ) + created_by_user = ( + db.query(Event.id) + .filter(by_owner_id) + .filter(get_date_filter_between_dates(start, end)) + .count() + ) + return { + "events_count_stats": { + "meetings_for_user": meetings_for_user, + "created_by_user": created_by_user, + }, + } + + +def get_events_by_date( + db: Session, + userid: int, + start: datetime.datetime, + end: datetime.datetime, +) -> List[Tuple[datetime.datetime, int]]: + """get date + number of events on it + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: + data of date + number of events on it. + """ + start_date = func.date(Event.start) + events_count = func.count(start_date) + by_user_id = UserEvent.user_id == userid + user_to_event = (UserEvent, UserEvent.event_id == Event.id) + return ( + db.query(start_date, events_count) + .join(user_to_event) + .filter(by_user_id) + .filter(get_date_filter_between_dates(start, end)) + .all() + ) + + +def calc_daily_events_statistics( + events_by_date: List[Tuple[datetime.datetime, int]], + start: datetime.datetime, + end: datetime.datetime, +) -> DailyEventsStatistics: + """go over sets of data retrieved from the db and calculate: + minimum, maximum and average number of daily events + Args: + events_by_date: data of date + number of events on it from the + get_events_by_date function + start: start of date range. + end: end of date range. + + Returns: + NamedTuple of: + min_events_in_day, max_events_in_day, avg_events_per_day + """ + num_of_days_in_period = (end - start).days + 1 + min_events_in_day = min(day[1] for day in events_by_date) + max_events_in_day = max(day[1] for day in events_by_date) + sum_events_per_period = sum(day[1] for day in events_by_date) + if num_of_days_in_period > len(events_by_date): + min_events_in_day = 0 + avg_events_per_day = round( + sum_events_per_period / num_of_days_in_period, + 2, + ) + return DailyEventsStatistics( + min_events_in_day, + max_events_in_day, + avg_events_per_day, + ) + + +def get_daily_events_statistics( + db: Session, + userid: int, + start: datetime.datetime, + end: datetime.datetime, +) -> Dict[str, Dict[str, int]]: + """calculate statistics for daily events relevant for the requested user + and the requested date range. logic is performed in: + the db (get_events_by_date function), + while the rest is in the code (calc_daily_events_statistics function). + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: json with + day_events_stats: (for requested date range). + min_events_in_day: minimum number of daily events the user has. + max_events_in_day: maximum number of daily events the user has. + avg_events_in_day: average number of daily events the user has. + """ + events_by_date = get_events_by_date(db, userid, start, end) + daily_events_statistics = calc_daily_events_statistics( + events_by_date, + start, + end, + ) + return { + "day_events_stats": { + "min_events_in_day": daily_events_statistics.min_events_in_day, + "max_events_in_day": daily_events_statistics.max_events_in_day, + "avg_events_per_day": daily_events_statistics.avg_events_per_day, + }, + } + + +def get_events_duration_statistics_from_db( + db: Session, + userid: int, + start: datetime.datetime, + end: datetime.datetime, +) -> EventsDurationStatistics: + """get data of shortest, longest and average event duration from the db + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: + NamedTuple of: shortest_event, longest_event, average_event + """ + event_duration = func.julianday(Event.end) - func.julianday(Event.start) + user_to_event = (UserEvent, UserEvent.event_id == Event.id) + by_user_id = UserEvent.user_id == userid + events_duration_statistics = ( + db.query( + (func.min(event_duration) * NIN_IN_DAY), + (func.max(event_duration) * NIN_IN_DAY), + (func.avg(event_duration) * NIN_IN_DAY), + ) + .join(user_to_event) + .filter(by_user_id) + .filter(get_date_filter_between_dates(start, end)) + .all() + ) + if events_duration_statistics[0][0]: + return EventsDurationStatistics( + shortest_event=round(events_duration_statistics[0][0]), + longest_event=round(events_duration_statistics[0][1]), + average_event=round(events_duration_statistics[0][2]), + ) + return EventsDurationStatistics( + shortest_event=0, + longest_event=0, + average_event=0, + ) + + +def get_events_duration_statistics( + db: Session, + userid: int, + start: datetime.datetime, + end: datetime.datetime, +) -> Dict[str, Dict[str, float]]: + """calculate statistics for events durations relevant for + the requested user and the requested date range. + all logic is performed in the db, while the rest is in the code. + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: json with + events_duration_statistics: (for requested date range, in minutes). + shortest_event: shortest event the user has. + longest_event: longest event the user has. + average_event: average event the user has. + """ + events_duration_statistics = get_events_duration_statistics_from_db( + db, + userid, + start, + end, + ) + return { + "events_duration_statistics": { + "shortest_event": events_duration_statistics.shortest_event, + "longest_event": events_duration_statistics.longest_event, + "average_event": events_duration_statistics.average_event, + }, + } + + +def get_participants_statistics( + db: Session, + userid: int, + start: datetime.datetime, + end: datetime.datetime, +) -> Dict[str, Dict[str, Union[str, int]]]: + """calculate statistics for events participants relevant for + the requested user and the requested date range. + part of the logic is performed in the db, + while the rest is in the code. + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: json with + max_events: maximum number of events the user has + with same participant. + participant_name: relevant participant name. + """ + by_user_id = UserEvent.user_id == userid + by_not_user_id = UserEvent.user_id != userid + user_to_event = (UserEvent, UserEvent.event_id == Event.id) + participant_count = func.count(UserEvent.user_id) + subquery = ( + db.query(Event.id) + .join(user_to_event) + .filter(by_user_id) + .filter(get_date_filter_between_dates(start, end)) + .subquery() + ) + event_participants = ( + db.query(UserEvent.user_id, participant_count) + .filter(by_not_user_id) + .filter(UserEvent.event_id.in_(subquery)) + .group_by(UserEvent.user_id) + .order_by(participant_count.desc()) + .first() + ) + if event_participants: + return { + "participants_statistics": { + "max_events": event_participants[1], + "participant_name": user.get_users( + db, + id=event_participants[0], + )[0].username, + }, + } + return { + "participants_statistics": {"max_events": 0, "participant_name": ""}, + } + + +def prepare_display_text( + output: Dict[str, Dict[str, Union[datetime.datetime, int, str]]], + start: datetime.datetime, + end: datetime.datetime, +) -> Dict[str, Dict[str, str]]: + """prepare display text per each statistics. + text summary for front end to display. + + Args: + output: data calculated by other functions. + start: start of date range. + end: end of date range. + + Returns: + input json + new section. + """ + display_text = { + "display_text": { + "title": f"Statistics for {start.date()} - {end.date()}", + "stat_1": f"You have " + f"{output['events_count_stats']['meetings_for_user']} " + f"events, {output['events_count_stats']['created_by_user']}" + f" you've created.", + "stat_2": f"Number of daily events: " + f"{output['day_events_stats']['min_events_in_day']} - " + f"{output['day_events_stats']['max_events_in_day']}" + f" events per day. Average of " + f"{output['day_events_stats']['avg_events_per_day']}" + f" events per day", + "stat_3": f"Shortest event is " + f"{output['events_duration_statistics']['shortest_event']} " + f"minutes, longest is " + f"{output['events_duration_statistics']['longest_event']} " + f"minutes with an average event of " + f"{output['events_duration_statistics']['average_event']} " + f"minutes", + "stat_4": f"Max events with the same person (" + f"{output['participants_statistics']['participant_name']}) " + f"is {output['participants_statistics']['max_events']}", + }, + } + return display_text + + +def update_output(output, *params) -> Dict[str, Union[str, bool]]: + """fetch all statistics by calling relevant functions: + 1. several functions for calculating statistics (1 per each area) + 2. function for preparing the display summary + + Args: + output: dictionary + list of parameters: + params[0] - db: db session. + params[1] - userid: requested user id number for statistics. + params[2] - start: start of date range. + params[3] - end: end of date range. + + Returns: + dictionary with statistics + """ + call_functions = [ + get_events_count_stats, + get_daily_events_statistics, + get_events_duration_statistics, + get_participants_statistics, + ] + for call_function in call_functions: + output.update(call_function(*params)) + output.update( + prepare_display_text( + output, + start=params[2], + end=params[3], + ), + ) + return output + + +def get_statistics( + db: Session, + userid: int, + start: datetime.datetime = None, + end: datetime.datetime = None, +) -> Dict[str, Union[str, bool]]: + """calculate statistics for user and date-range - main function. + + Args: + db: db session. + userid: requested user id number for statistics. + start: start of date range. + end: end of date range. + + Returns: dictionary with the following entries: + 1. events_count_stats: (for requested date range). + meetings_for_user: events that the user has. + created_by_user: events that the user has created. + 2. day_events_stats: (for requested date range). + min_events_in_day: minimum number of daily events the user has. + max_events_in_day: maximum number of daily events the user has. + avg_events_in_day: average number of daily events the user has. + 3. events_duration_statistics: (for requested date range, in minutes). + shortest_event: shortest event the user has. + longest_event: longest event the user has. + average_event: average event the user has. + 4. participants_statistics: (for requested date range). + max_events: maximum number of events the user has + with same participant. + participant_name: relevant participant name. + 5. display_text: (summary text to display in calendar). + title: display title. + stat_1: display summary for "events_count_stats". + stat_2: display summary for "day_events_stats". + stat_3: display summary for "events_duration_statistics". + stat_4: display summary for "participants_statistics". + """ + output = {} + params = [db, userid, start, end] + validate_result = validate_input(*params) + if not validate_result.valid_input: + output["start"] = validate_result.start + output["end"] = validate_result.end + output["status"] = ERROR_STATUS + output["error_description"] = validate_result.error_text + return output + output["status"] = SUCCESS_STATUS + output["error_description"] = "" + params = [db, userid, validate_result.start, validate_result.end] + output = update_output(output, *params) + return output diff --git a/app/internal/todo_list.py b/app/internal/todo_list.py new file mode 100644 index 00000000..2de257aa --- /dev/null +++ b/app/internal/todo_list.py @@ -0,0 +1,35 @@ +from datetime import date, time + +from sqlalchemy.orm import Session + +from app.database.models import Task +from app.internal.utils import create_model + + +def create_task( + db: Session, + title: str, + description: str, + date_str: date, + time_str: time, + owner_id: int, + is_important: bool, +) -> Task: + """Creates and saves a new task.""" + task = create_model( + db, + Task, + title=title, + description=description, + date=date_str, + time=time_str, + owner_id=owner_id, + is_important=is_important, + is_done=False, + ) + return task + + +def by_id(db: Session, task_id: int) -> Task: + task = db.query(Task).filter_by(id=task_id).one() + return task diff --git a/app/internal/translation.py b/app/internal/translation.py index e033781f..37f4ee3c 100644 --- a/app/internal/translation.py +++ b/app/internal/translation.py @@ -5,11 +5,11 @@ from loguru import logger from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from sqlalchemy.orm.session import Session -from textblob import download_corpora, TextBlob +from textblob import TextBlob, download_corpora from textblob.exceptions import NotTranslated from app.database.models import Language -from app.routers.user import get_users +from app.internal.user.user import get_users download_corpora.download_all() @@ -31,10 +31,11 @@ def translate_text_for_user(text: str, session: Session, user_id: int) -> str: return translate_text(text, target_lang) -def translate_text(text: str, - target_lang: str, - original_lang: Optional[str] = None, - ) -> str: +def translate_text( + text: str, + target_lang: str, + original_lang: Optional[str] = None, +) -> str: """Translates text to the target language. Args: @@ -56,9 +57,12 @@ def translate_text(text: str, return text try: - return str(TextBlob(text).translate( - from_lang=language_code, - to=_get_language_code(target_lang))) + return str( + TextBlob(text).translate( + from_lang=language_code, + to=_get_language_code(target_lang), + ), + ) except NotTranslated: return text @@ -88,7 +92,7 @@ def _get_user_language(user_id: int, session: Session) -> str: logger.critical(e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Error raised', + detail="Error raised", ) diff --git a/app/internal/user/__init__.py b/app/internal/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/internal/user/availability.py b/app/internal/user/availability.py index a94d5a8b..f855ea75 100644 --- a/app/internal/user/availability.py +++ b/app/internal/user/availability.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.database.models import Event, User + # from app.internal.utils import get_current_user @@ -11,8 +12,11 @@ def disable(session: Session, user_id: int) -> bool: returns: True if function worked properly False if it didn't.""" - future_events_user_owns = session.query(Event).filter( - Event.start > datetime.now(), Event.owner_id == user_id).all() + future_events_user_owns = ( + session.query(Event) + .filter(Event.start > datetime.now(), Event.owner_id == user_id) + .all() + ) if future_events_user_owns: return False diff --git a/app/internal/user/user.py b/app/internal/user/user.py new file mode 100644 index 00000000..13f2a09e --- /dev/null +++ b/app/internal/user/user.py @@ -0,0 +1,132 @@ +from typing import List + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.database import models, schemas +from app.internal.security.ouath2 import get_hashed_password +from app.internal.utils import save + + +def get_by_id(db: Session, user_id: int) -> models.User: + """query database for a user by unique id""" + return db.query(models.User).filter(models.User.id == user_id).first() + + +def get_by_username(db: Session, username: str) -> models.User: + """query database for a user by unique username""" + return ( + db.query(models.User).filter(models.User.username == username).first() + ) + + +def get_by_mail(db: Session, email: str) -> models.User: + """query database for a user by unique email""" + return db.query(models.User).filter(models.User.email == email).first() + + +def create(db: Session, user: schemas.UserCreate) -> models.User: + """ + creating a new User object in the database, with hashed password + """ + unhashed_password = user.password.encode("utf-8") + hashed_password = get_hashed_password(unhashed_password) + user_details = { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "password": hashed_password, + "description": user.description, + } + db_user = models.User(**user_details) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def delete_by_mail(db: Session, email: str) -> None: + """deletes a user from database by unique email""" + db_user = get_by_mail(db=db, email=email) + db.delete(db_user) + db.commit() + + +def get_users(session: Session, **param): + """Returns all users filtered by param.""" + try: + users = list(session.query(models.User).filter_by(**param)) + except SQLAlchemyError: + return [] + else: + return users + + +def does_user_exist( + session: Session, *, user_id=None, username=None, email=None +): + """Returns True if user exists, False otherwise. + function can receive one of the there parameters""" + if user_id: + return len(get_users(session=session, id=user_id)) == 1 + if username: + return len(get_users(session=session, username=username)) == 1 + if email: + return len(get_users(session=session, email=email)) == 1 + return False + + +def get_all_user_events(session: Session, user_id: int) -> List[models.Event]: + """Returns all events that the user participants in.""" + return ( + session.query(models.Event) + .join(models.UserEvent) + .filter(models.UserEvent.user_id == user_id) + .all() + ) + + +def _create_user(session, **kw) -> models.User: + """Creates and saves a new user.""" + user = models.User(**kw) + save(session, user) + return user + + +async def create_user(db: Session, user: schemas.UserCreate) -> models.User: + """ + creating a new User object in the database, with hashed password + """ + unhashed_password = user.password.encode("utf-8") + hashed_password = get_hashed_password(unhashed_password) + user_details = { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "password": hashed_password, + "description": user.description, + "language_id": user.language_id, + "target_weight": user.target_weight, + } + return _create_user(**user_details, session=db) + + +async def check_unique_fields( + db: Session, + new_user: schemas.UserCreate, +) -> dict: + """Verifying new user details are unique. Return relevant errors""" + errors = {} + if db.query( + db.query(models.User) + .filter(models.User.username == new_user.username) + .exists(), + ).scalar(): + errors["username"] = "That username is already taken" + if db.query( + db.query(models.User) + .filter(models.User.email == new_user.email) + .exists(), + ).scalar(): + errors["email"] = "Email already registered" + return errors diff --git a/app/internal/utils.py b/app/internal/utils.py index 6b96590f..a7e208f5 100644 --- a/app/internal/utils.py +++ b/app/internal/utils.py @@ -1,6 +1,9 @@ -from typing import Any, List, Optional +from datetime import date, datetime, time +from typing import Any, List, Optional, Union from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse +from starlette.status import HTTP_302_FOUND from app.database.models import Base, User @@ -18,6 +21,7 @@ def save(session: Session, instance: Base) -> bool: def create_model(session: Session, model_class: Base, **kwargs: Any) -> Base: """Creates and saves a db model.""" instance = model_class(**kwargs) + save(session, instance) return instance @@ -41,7 +45,7 @@ def get_current_user(session: Session) -> User: def get_available_users(session: Session) -> List[User]: - """this function return all availible users.""" + """this function return all available users.""" return session.query(User).filter(not User.disabled).all() @@ -58,6 +62,26 @@ def get_user(session: Session, user_id: int) -> Optional[User]: return session.query(User).filter_by(id=user_id).first() +def get_time_from_string(string: str) -> Optional[Union[date, time]]: + """Converts time string to a date or time object. + + Args: + string (str): Time string. + + Returns: + datetime.time | datetime.date | None: Date or Time object if valid, + None otherwise. + """ + formats = {"%Y-%m-%d": "date", "%H:%M": "time", "%H:%M:%S": "time"} + for time_format, method in formats.items(): + try: + time_obj = getattr(datetime.strptime(string, time_format), method) + except ValueError: + pass + else: + return time_obj() + + def get_placeholder_user() -> User: """Creates a mock user. @@ -68,10 +92,31 @@ def get_placeholder_user() -> User: A User object. """ return User( - username='new_user', - email='my@email.po', - password='1a2s3d4f5g6', - full_name='My Name', + username="new_user", + email="my@email.po", + password="1a2s3d4f5g6", + full_name="My Name", language_id=1, - telegram_id='', + telegram_id="", ) + + +def safe_redirect_response( + url: str, + default: str = "/", + status_code: int = HTTP_302_FOUND, +): + """Returns a safe redirect response. + + Args: + url: the url to redirect to. + default: where to redirect if url isn't safe. + status_code: the response status code. + + Returns: + The Notifications HTML page. + """ + if not url.startswith("/"): + url = default + + return RedirectResponse(url=url, status_code=status_code) diff --git a/app/locales/en/LC_MESSAGES/base.po b/app/locales/en/LC_MESSAGES/base.po index 87e32799..948c8761 100644 --- a/app/locales/en/LC_MESSAGES/base.po +++ b/app/locales/en/LC_MESSAGES/base.po @@ -130,4 +130,3 @@ msgstr "" #~ msgid "Agenda" #~ msgstr "" - diff --git a/app/locales/he/LC_MESSAGES/base.po b/app/locales/he/LC_MESSAGES/base.po index c5e559a2..959b1f6d 100644 --- a/app/locales/he/LC_MESSAGES/base.po +++ b/app/locales/he/LC_MESSAGES/base.po @@ -130,4 +130,3 @@ msgstr "בדיקת תרגום בפייתון" #~ msgid "Agenda" #~ msgstr "" - diff --git a/app/main.py b/app/main.py index 39e24cdc..67136680 100644 --- a/app/main.py +++ b/app/main.py @@ -6,22 +6,34 @@ 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 get_db, logger, MEDIA_PATH, STATIC_PATH, templates +from app.dependencies import ( + MEDIA_PATH, + SOUNDS_PATH, + STATIC_PATH, + UPLOAD_PATH, + SessionLocal, + get_db, + logger, + templates, +) from app.internal import daily_quotes, json_data_loader from app.internal.languages import set_ui_language +from app.internal.security.dependencies import get_jinja_current_user from app.internal.security.ouath2 import auth_exception_handler +from app.routers.notes import notes from app.routers.salary import routes as salary from app.utils.extending_openapi import custom_openapi def create_tables(engine, psql_environment): - if 'sqlite' in str(engine.url) and psql_environment: + if "sqlite" in str(engine.url) and psql_environment: raise models.PSQLEnvironmentError( "You're trying to use PSQL features on SQLite env.\n" "Please set app.config.PSQL_ENVIRONMENT to False " - "and run the app again." + "and run the app again.", ) else: models.Base.metadata.create_all(bind=engine) @@ -32,18 +44,53 @@ def create_tables(engine, psql_environment): app = FastAPI(title="Pylander", docs_url=None) app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") +app.mount( + "/event_images", + StaticFiles(directory=UPLOAD_PATH), + name="event_images", +) +app.mount("/static/tracks", StaticFiles(directory=SOUNDS_PATH), name="sounds") app.logger = logger app.add_exception_handler(status.HTTP_401_UNAUTHORIZED, auth_exception_handler) +templates.env.globals["jinja_current_user"] = get_jinja_current_user # This MUST come before the app.routers imports. set_ui_language() from app.routers import ( # noqa: E402 - about_us, agenda, calendar, categories, celebrity, credits, - currency, dayview, email, event, export, four_o_four, friendview, - google_connect, invitation, login, logout, profile, - register, search, telegram, user, weekview, whatsapp, + about_us, + agenda, + audio, + calendar, + categories, + celebrity, + credits, + currency, + dayview, + email, + event, + export, + features, + four_o_four, + friendview, + google_connect, + joke, + login, + logout, + meds, + notification, + profile, + register, + reset_password, + search, + settings, + telegram, + todo_list, + user, + weekview, + weight, + whatsapp, ) json_data_loader.load_to_database(next(get_db())) @@ -68,28 +115,37 @@ async def swagger_ui_redirect(): routers_to_include = [ about_us.router, agenda.router, + audio.router, calendar.router, categories.router, celebrity.router, credits.router, currency.router, dayview.router, - friendview.router, - weekview.router, email.router, event.router, export.router, + features.router, four_o_four.router, + friendview.router, google_connect.router, - invitation.router, + joke.router, login.router, logout.router, + meds.router, + notes.router, + notification.router, profile.router, register.router, + reset_password.router, salary.router, search.router, + settings.router, telegram.router, + todo_list.router, user.router, + weekview.router, + weight.router, whatsapp.router, ] @@ -97,16 +153,26 @@ async def swagger_ui_redirect(): app.include_router(router) +@app.on_event("startup") +async def startup_event(): + session = SessionLocal() + internal_features.create_features_at_startup(session=session) + 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) - return templates.TemplateResponse("index.html", { - "request": request, - "quote": quote, - }) + return templates.TemplateResponse( + "index.html", + { + "request": request, + "quote": quote, + }, + ) custom_openapi(app) diff --git a/app/media/arrow-left.png b/app/media/arrow-left.png new file mode 100644 index 00000000..12ef74f7 Binary files /dev/null and b/app/media/arrow-left.png differ diff --git a/app/media/user1.png b/app/media/new_user.png similarity index 100% rename from app/media/user1.png rename to app/media/new_user.png diff --git a/app/resources/countries.py b/app/resources/countries.py new file mode 100644 index 00000000..a50f0ff7 --- /dev/null +++ b/app/resources/countries.py @@ -0,0 +1,639 @@ +# List was taken from: +# https://gist.github.com/stasius12/f95f2999fa351212991c43a5f067c78d + +countries = [ + {'timezones': ['Asia/Kabul'], + 'code': 'AF', 'name': 'Afghanistan'}, + {'timezones': ['Europe/Mariehamn'], + 'code': 'AX', 'name': 'Aland Islands'}, + {'timezones': ['Europe/Tirane'], + 'code': 'AL', 'name': 'Albania'}, + {'timezones': ['Africa/Algiers'], + 'code': 'DZ', 'name': 'Algeria'}, + {'timezones': ['Pacific/Pago_Pago'], + 'code': 'AS', 'name': 'American Samoa'}, + {'timezones': ['Europe/Andorra'], + 'code': 'AD', 'name': 'Andorra'}, + {'timezones': ['Africa/Luanda'], + 'code': 'AO', 'name': 'Angola'}, + {'timezones': ['America/Anguilla'], + 'code': 'AI', 'name': 'Anguilla'}, + {'timezones': [ + 'Antarctica/Casey', + 'Antarctica/Davis', 'Antarctica/DumontDUrville', + 'Antarctica/Mawson', 'Antarctica/McMurdo', + 'Antarctica/Palmer', 'Antarctica/Rothera', + 'Antarctica/Syowa', 'Antarctica/Troll', + 'Antarctica/Vostok'], + 'code': 'AQ', + 'name': 'Antarctica'}, + {'timezones': ['America/Antigua'], + 'code': 'AG', 'name': 'Antigua and Barbuda'}, + {'timezones': [ + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia'], + 'code': 'AR', 'name': 'Argentina'}, + {'timezones': ['Asia/Yerevan'], + 'code': 'AM', 'name': 'Armenia'}, + {'timezones': ['America/Aruba'], + 'code': 'AW', 'name': 'Aruba'}, + {'timezones': [ + 'Antarctica/Macquarie', + 'Australia/Adelaide', 'Australia/Brisbane', + 'Australia/Broken_Hill', 'Australia/Currie', + 'Australia/Darwin', 'Australia/Eucla', + 'Australia/Hobart', 'Australia/Lindeman', + 'Australia/Lord_Howe', 'Australia/Melbourne', + 'Australia/Perth', 'Australia/Sydney'], + 'code': 'AU', 'name': 'Australia'}, + {'timezones': ['Europe/Vienna'], + 'code': 'AT', 'name': 'Austria'}, + {'timezones': ['Asia/Baku'], + 'code': 'AZ', 'name': 'Azerbaijan'}, + {'timezones': ['America/Nassau'], + 'code': 'BS', 'name': 'Bahamas'}, + {'timezones': ['Asia/Bahrain'], + 'code': 'BH', 'name': 'Bahrain'}, + {'timezones': ['Asia/Dhaka'], + 'code': 'BD', 'name': 'Bangladesh'}, + {'timezones': ['America/Barbados'], + 'code': 'BB', 'name': 'Barbados'}, + {'timezones': ['Europe/Minsk'], + 'code': 'BY', 'name': 'Belarus'}, + {'timezones': ['Europe/Brussels'], + 'code': 'BE', 'name': 'Belgium'}, + {'timezones': ['America/Belize'], + 'code': 'BZ', 'name': 'Belize'}, + {'timezones': ['Africa/Porto-Novo'], + 'code': 'BJ', 'name': 'Benin'}, + {'timezones': ['Atlantic/Bermuda'], + 'code': 'BM', 'name': 'Bermuda'}, + {'timezones': ['Asia/Thimphu'], + 'code': 'BT', 'name': 'Bhutan'}, + {'timezones': ['America/La_Paz'], + 'code': 'BO', 'name': 'Bolivia'}, + {'timezones': ['America/Kralendijk'], + 'code': 'BQ', 'name': 'Bonaire, Saint Eustatius and Saba '}, + {'timezones': ['Europe/Sarajevo'], + 'code': 'BA', 'name': 'Bosnia and Herzegovina'}, + {'timezones': ['Africa/Gaborone'], + 'code': 'BW', 'name': 'Botswana'}, + {'timezones': [ + 'America/Araguaina', + 'America/Bahia', 'America/Belem', + 'America/Boa_Vista', 'America/Campo_Grande', + 'America/Cuiaba', 'America/Eirunepe', + 'America/Fortaleza', 'America/Maceio', + 'America/Manaus', 'America/Noronha', + 'America/Porto_Velho', 'America/Recife', + 'America/Rio_Branco', 'America/Santarem', + 'America/Sao_Paulo'], + 'code': 'BR', 'name': 'Brazil'}, + {'timezones': ['Indian/Chagos'], + 'code': 'IO', 'name': 'British Indian Ocean Territory'}, + {'timezones': ['America/Tortola'], + 'code': 'VG', 'name': 'British Virgin Islands'}, + {'timezones': ['Asia/Brunei'], + 'code': 'BN', 'name': 'Brunei'}, + {'timezones': ['Europe/Sofia'], + 'code': 'BG', 'name': 'Bulgaria'}, + {'timezones': ['Africa/Ouagadougou'], + 'code': 'BF', 'name': 'Burkina Faso'}, + {'timezones': ['Africa/Bujumbura'], + 'code': 'BI', 'name': 'Burundi'}, + {'timezones': ['Asia/Phnom_Penh'], + 'code': 'KH', 'name': 'Cambodia'}, + {'timezones': ['Africa/Douala'], + 'code': 'CM', 'name': 'Cameroon'}, + {'timezones': [ + 'America/Atikokan', + 'America/Blanc-Sablon', 'America/Cambridge_Bay', + 'America/Montreal', 'America/Creston', + 'America/Dawson', 'America/Dawson_Creek', + 'America/Edmonton', 'America/Fort_Nelson', + 'America/Glace_Bay', 'America/Goose_Bay', + 'America/Halifax', 'America/Inuvik', + 'America/Iqaluit', 'America/Moncton', + 'America/Nipigon', 'America/Pangnirtung', + 'America/Rainy_River', 'America/Rankin_Inlet', + 'America/Regina', 'America/Resolute', + 'America/St_Johns', 'America/Swift_Current', + 'America/Thunder_Bay', 'America/Toronto', + 'America/Vancouver', 'America/Whitehorse', + 'America/Winnipeg', 'America/Yellowknife'], + 'code': 'CA', 'name': 'Canada'}, + {'timezones': ['Atlantic/Cape_Verde'], + 'code': 'CV', 'name': 'Cape Verde'}, + {'timezones': ['America/Cayman'], + 'code': 'KY', 'name': 'Cayman Islands'}, + {'timezones': ['Africa/Bangui'], + 'code': 'CF', 'name': 'Central African Republic'}, + {'timezones': ['Africa/Ndjamena'], + 'code': 'TD', 'name': 'Chad'}, + {'timezones': [ + 'America/Punta_Arenas', + 'America/Santiago', 'Pacific/Easter'], + 'code': 'CL', 'name': 'Chile'}, + {'timezones': [ + 'Asia/Shanghai', + 'Asia/Urumqi', 'Asia/Chungking', + 'Asia/Chongqing'], + 'code': 'CN', 'name': 'China'}, + {'timezones': ['Indian/Christmas'], + 'code': 'CX', 'name': 'Christmas Island'}, + {'timezones': ['Indian/Cocos'], + 'code': 'CC', 'name': 'Cocos Islands'}, + {'timezones': ['America/Bogota'], + 'code': 'CO', 'name': 'Colombia'}, + {'timezones': ['Indian/Comoro'], + 'code': 'KM', 'name': 'Comoros'}, + {'timezones': ['Pacific/Rarotonga'], + 'code': 'CK', 'name': 'Cook Islands'}, + {'timezones': ['America/Costa_Rica'], + 'code': 'CR', 'name': 'Costa Rica'}, + {'timezones': ['Europe/Zagreb'], + 'code': 'HR', 'name': 'Croatia'}, + {'timezones': ['America/Havana'], + 'code': 'CU', 'name': 'Cuba'}, + {'timezones': ['America/Curacao'], + 'code': 'CW', 'name': 'Curacao'}, + {'timezones': [ + 'Asia/Famagusta', + 'Asia/Nicosia'], + 'code': 'CY', 'name': 'Cyprus'}, + {'timezones': ['Europe/Prague'], + 'code': 'CZ', 'name': 'Czech Republic'}, + {'timezones': [ + 'Africa/Kinshasa', + 'Africa/Lubumbashi'], + 'code': 'CD', + 'name': 'Democratic Republic of the Congo'}, + {'timezones': ['Europe/Copenhagen'], + 'code': 'DK', 'name': 'Denmark'}, + {'timezones': ['Africa/Djibouti'], + 'code': 'DJ', 'name': 'Djibouti'}, + {'timezones': ['America/Dominica'], + 'code': 'DM', 'name': 'Dominica'}, + {'timezones': ['America/Santo_Domingo'], + 'code': 'DO', 'name': 'Dominican Republic'}, + {'timezones': ['Asia/Dili'], + 'code': 'TL', 'name': 'East Timor'}, + {'timezones': [ + 'America/Guayaquil', + 'Pacific/Galapagos'], + 'code': 'EC', + 'name': 'Ecuador'}, + {'timezones': ['Africa/Cairo'], + 'code': 'EG', 'name': 'Egypt'}, + {'timezones': ['America/El_Salvador'], + 'code': 'SV', 'name': 'El Salvador'}, + {'timezones': ['Africa/Malabo'], + 'code': 'GQ', 'name': 'Equatorial Guinea'}, + {'timezones': ['Africa/Asmara'], + 'code': 'ER', 'name': 'Eritrea'}, + {'timezones': ['Europe/Tallinn'], + 'code': 'EE', 'name': 'Estonia'}, + {'timezones': ['Africa/Addis_Ababa'], + 'code': 'ET', 'name': 'Ethiopia'}, + {'timezones': ['Atlantic/Stanley'], + 'code': 'FK', 'name': 'Falkland Islands'}, + {'timezones': ['Atlantic/Faroe'], + 'code': 'FO', 'name': 'Faroe Islands'}, + {'timezones': ['Pacific/Fiji'], + 'code': 'FJ', 'name': 'Fiji'}, + {'timezones': ['Europe/Helsinki'], + 'code': 'FI', 'name': 'Finland'}, + {'timezones': ['Europe/Paris'], + 'code': 'FR', 'name': 'France'}, + {'timezones': ['America/Cayenne'], + 'code': 'GF', 'name': 'French Guiana'}, + {'timezones': [ + 'Pacific/Gambier', + 'Pacific/Marquesas', 'Pacific/Tahiti'], + 'code': 'PF', 'name': 'French Polynesia'}, + {'timezones': ['Indian/Kerguelen'], + 'code': 'TF', 'name': 'French Southern Territories'}, + {'timezones': ['Africa/Libreville'], + 'code': 'GA', 'name': 'Gabon'}, + {'timezones': ['Africa/Banjul'], + 'code': 'GM', 'name': 'Gambia'}, + {'timezones': ['Asia/Tbilisi'], + 'code': 'GE', 'name': 'Georgia'}, + {'timezones': [ + 'Europe/Berlin', + 'Europe/Busingen'], + 'code': 'DE', 'name': 'Germany'}, + {'timezones': ['Africa/Accra'], + 'code': 'GH', 'name': 'Ghana'}, + {'timezones': ['Europe/Gibraltar'], + 'code': 'GI', 'name': 'Gibraltar'}, + {'timezones': ['Europe/Athens'], + 'code': 'GR', 'name': 'Greece'}, + {'timezones': [ + 'America/Danmarkshavn', + 'America/Godthab', 'America/Scoresbysund', + 'America/Thule'], + 'code': 'GL', 'name': 'Greenland'}, + {'timezones': ['America/Grenada'], + 'code': 'GD', 'name': 'Grenada'}, + {'timezones': ['America/Guadeloupe'], + 'code': 'GP', 'name': 'Guadeloupe'}, + {'timezones': ['Pacific/Guam'], + 'code': 'GU', 'name': 'Guam'}, + {'timezones': ['America/Guatemala'], + 'code': 'GT', 'name': 'Guatemala'}, + {'timezones': ['Europe/Guernsey'], + 'code': 'GG', 'name': 'Guernsey'}, + {'timezones': ['Africa/Conakry'], + 'code': 'GN', 'name': 'Guinea'}, + {'timezones': ['Africa/Bissau'], + 'code': 'GW', 'name': 'Guinea-Bissau'}, + {'timezones': ['America/Guyana'], + 'code': 'GY', 'name': 'Guyana'}, + {'timezones': ['America/Port-au-Prince'], + 'code': 'HT', 'name': 'Haiti'}, + {'timezones': ['America/Tegucigalpa'], + 'code': 'HN', 'name': 'Honduras'}, + {'timezones': ['Asia/Hong_Kong'], + 'code': 'HK', 'name': 'Hong Kong'}, + {'timezones': ['Europe/Budapest'], + 'code': 'HU', 'name': 'Hungary'}, + {'timezones': ['Atlantic/Reykjavik'], + 'code': 'IS', 'name': 'Iceland'}, + {'timezones': [ + 'Asia/Kolkata', + 'Asia/Calcutta'], + 'code': 'IN', 'name': 'India'}, + {'timezones': [ + 'Asia/Jakarta', + 'Asia/Jayapura', 'Asia/Makassar', + 'Asia/Pontianak'], + 'code': 'ID', 'name': 'Indonesia'}, + {'timezones': ['Asia/Tehran'], + 'code': 'IR', 'name': 'Iran'}, + {'timezones': ['Asia/Baghdad'], + 'code': 'IQ', 'name': 'Iraq'}, + {'timezones': ['Europe/Dublin'], + 'code': 'IE', 'name': 'Ireland'}, + {'timezones': ['Europe/Isle_of_Man'], + 'code': 'IM', 'name': 'Isle of Man'}, + {'timezones': ['Asia/Jerusalem'], + 'code': 'IL', 'name': 'Israel'}, + {'timezones': ['Europe/Rome'], + 'code': 'IT', 'name': 'Italy'}, + {'timezones': ['Africa/Abidjan'], + 'code': 'CI', 'name': 'Ivory Coast'}, + {'timezones': ['America/Jamaica'], + 'code': 'JM', 'name': 'Jamaica'}, + {'timezones': ['Asia/Tokyo'], + 'code': 'JP', 'name': 'Japan'}, + {'timezones': ['Europe/Jersey'], + 'code': 'JE', 'name': 'Jersey'}, + {'timezones': ['Asia/Amman'], + 'code': 'JO', 'name': 'Jordan'}, + {'timezones': [ + 'Asia/Almaty', + 'Asia/Aqtau', 'Asia/Aqtobe', + 'Asia/Atyrau', 'Asia/Oral', + 'Asia/Qyzylorda'], + 'code': 'KZ', + 'name': 'Kazakhstan'}, + {'timezones': ['Africa/Nairobi'], + 'code': 'KE', 'name': 'Kenya'}, + {'timezones': [ + 'Pacific/Enderbury', + 'Pacific/Kiritimati', 'Pacific/Tarawa'], + 'code': 'KI', 'name': 'Kiribati'}, + {'timezones': ['Asia/Kuwait'], + 'code': 'KW', 'name': 'Kuwait'}, + {'timezones': ['Asia/Bishkek'], + 'code': 'KG', 'name': 'Kyrgyzstan'}, + {'timezones': ['Asia/Vientiane'], + 'code': 'LA', 'name': 'Laos'}, + {'timezones': ['Europe/Riga'], + 'code': 'LV', 'name': 'Latvia'}, + {'timezones': ['Asia/Beirut'], + 'code': 'LB', 'name': 'Lebanon'}, + {'timezones': ['Africa/Maseru'], + 'code': 'LS', 'name': 'Lesotho'}, + {'timezones': ['Africa/Monrovia'], + 'code': 'LR', 'name': 'Liberia'}, + {'timezones': ['Africa/Tripoli'], + 'code': 'LY', 'name': 'Libya'}, + {'timezones': ['Europe/Vaduz'], + 'code': 'LI', 'name': 'Liechtenstein'}, + {'timezones': ['Europe/Vilnius'], + 'code': 'LT', 'name': 'Lithuania'}, + {'timezones': ['Europe/Luxembourg'], + 'code': 'LU', 'name': 'Luxembourg'}, + {'timezones': ['Asia/Macau'], + 'code': 'MO', 'name': 'Macao'}, + {'timezones': ['Europe/Skopje'], + 'code': 'MK', 'name': 'Macedonia'}, + {'timezones': ['Indian/Antananarivo'], + 'code': 'MG', 'name': 'Madagascar'}, + {'timezones': ['Africa/Blantyre'], + 'code': 'MW', 'name': 'Malawi'}, + {'timezones': [ + 'Asia/Kuala_Lumpur', + 'Asia/Kuching'], + 'code': 'MY', 'name': 'Malaysia'}, + {'timezones': ['Indian/Maldives'], + 'code': 'MV', 'name': 'Maldives'}, + {'timezones': ['Africa/Bamako'], + 'code': 'ML', 'name': 'Mali'}, + {'timezones': ['Europe/Malta'], + 'code': 'MT', 'name': 'Malta'}, + {'timezones': [ + 'Pacific/Kwajalein', + 'Pacific/Majuro'], + 'code': 'MH', + 'name': 'Marshall Islands'}, + {'timezones': ['America/Martinique'], + 'code': 'MQ', 'name': 'Martinique'}, + {'timezones': ['Africa/Nouakchott'], + 'code': 'MR', 'name': 'Mauritania'}, + {'timezones': ['Indian/Mauritius'], + 'code': 'MU', 'name': 'Mauritius'}, + {'timezones': ['Indian/Mayotte'], + 'code': 'YT', 'name': 'Mayotte'}, + {'timezones': [ + 'America/Bahia_Banderas', + 'America/Cancun', 'America/Chihuahua', + 'America/Hermosillo', 'America/Matamoros', + 'America/Mazatlan', 'America/Merida', + 'America/Mexico_City', 'America/Monterrey', + 'America/Ojinaga', 'America/Tijuana'], + 'code': 'MX', 'name': 'Mexico'}, + {'timezones': [ + 'Pacific/Chuuk', + 'Pacific/Kosrae', 'Pacific/Pohnpei'], + 'code': 'FM', 'name': 'Micronesia'}, + {'timezones': ['Europe/Chisinau'], + 'code': 'MD', 'name': 'Moldova'}, + {'timezones': ['Europe/Monaco'], + 'code': 'MC', 'name': 'Monaco'}, + {'timezones': [ + 'Asia/Choibalsan', + 'Asia/Hovd', 'Asia/Ulaanbaatar'], + 'code': 'MN', 'name': 'Mongolia'}, + {'timezones': ['Europe/Podgorica'], + 'code': 'ME', 'name': 'Montenegro'}, + {'timezones': ['America/Montserrat'], + 'code': 'MS', 'name': 'Montserrat'}, + {'timezones': ['Africa/Casablanca'], + 'code': 'MA', 'name': 'Morocco'}, + {'timezones': ['Africa/Maputo'], + 'code': 'MZ', 'name': 'Mozambique'}, + {'timezones': ['Africa/Windhoek'], + 'code': 'NA', 'name': 'Namibia'}, + {'timezones': ['Pacific/Nauru'], + 'code': 'NR', 'name': 'Nauru'}, + {'timezones': [ + 'Asia/Kathmandu', + 'Asia/Katmandu'], + 'code': 'NP', + 'name': 'Nepal'}, + {'timezones': ['Europe/Amsterdam'], + 'code': 'NL', 'name': 'Netherlands'}, + {'timezones': ['Pacific/Noumea'], + 'code': 'NC', 'name': 'New Caledonia'}, + {'timezones': [ + 'Pacific/Auckland', + 'Pacific/Chatham'], + 'code': 'NZ', + 'name': 'New Zealand'}, + {'timezones': ['America/Managua'], + 'code': 'NI', 'name': 'Nicaragua'}, + {'timezones': ['Africa/Niamey'], + 'code': 'NE', 'name': 'Niger'}, + {'timezones': ['Africa/Lagos'], + 'code': 'NG', 'name': 'Nigeria'}, + {'timezones': ['Pacific/Niue'], + 'code': 'NU', 'name': 'Niue'}, + {'timezones': ['Pacific/Norfolk'], + 'code': 'NF', 'name': 'Norfolk Island'}, + {'timezones': ['Asia/Pyongyang'], + 'code': 'KP', 'name': 'North Korea'}, + {'timezones': ['Pacific/Saipan'], + 'code': 'MP', 'name': 'Northern Mariana Islands'}, + {'timezones': ['Europe/Oslo'], + 'code': 'NO', 'name': 'Norway'}, + {'timezones': ['Asia/Muscat'], + 'code': 'OM', 'name': 'Oman'}, + {'timezones': ['Asia/Karachi'], + 'code': 'PK', 'name': 'Pakistan'}, + {'timezones': ['Pacific/Palau'], + 'code': 'PW', 'name': 'Palau'}, + {'timezones': ['Asia/Gaza', 'Asia/Hebron'], + 'code': 'PS', 'name': 'Palestinian Territory'}, + {'timezones': ['America/Panama'], + 'code': 'PA', 'name': 'Panama'}, + {'timezones': [ + 'Pacific/Bougainville', + 'Pacific/Port_Moresby'], + 'code': 'PG', 'name': 'Papua New Guinea'}, + {'timezones': ['America/Asuncion'], + 'code': 'PY', 'name': 'Paraguay'}, + {'timezones': ['America/Lima'], + 'code': 'PE', 'name': 'Peru'}, + {'timezones': ['Asia/Manila'], + 'code': 'PH', 'name': 'Philippines'}, + {'timezones': ['Pacific/Pitcairn'], + 'code': 'PN', 'name': 'Pitcairn'}, + {'timezones': ['Europe/Warsaw'], + 'code': 'PL', 'name': 'Poland'}, + {'timezones': [ + 'Atlantic/Azores', + 'Atlantic/Madeira', 'Europe/Lisbon'], + 'code': 'PT', 'name': 'Portugal'}, + {'timezones': ['America/Puerto_Rico'], + 'code': 'PR', 'name': 'Puerto Rico'}, + {'timezones': ['Asia/Qatar'], + 'code': 'QA', 'name': 'Qatar'}, + {'timezones': ['Africa/Brazzaville'], + 'code': 'CG', 'name': 'Republic of the Congo'}, + {'timezones': ['Indian/Reunion'], + 'code': 'RE', 'name': 'Reunion'}, + {'timezones': ['Europe/Bucharest'], + 'code': 'RO', 'name': 'Romania'}, + {'timezones': [ + 'Asia/Anadyr', 'Asia/Barnaul', + 'Asia/Chita', 'Asia/Irkutsk', 'Asia/Kamchatka', + 'Asia/Khandyga', 'Asia/Krasnoyarsk', 'Asia/Magadan', + 'Asia/Novokuznetsk', 'Asia/Novosibirsk', 'Asia/Omsk', + 'Asia/Sakhalin', 'Asia/Srednekolymsk', 'Asia/Tomsk', + 'Asia/Ust-Nera', 'Asia/Vladivostok', 'Asia/Yakutsk', + 'Asia/Yekaterinburg', 'Europe/Astrakhan', + 'Europe/Kaliningrad', 'Europe/Kirov', + 'Europe/Moscow', 'Europe/Samara', + 'Europe/Saratov', 'Europe/Simferopol', + 'Europe/Ulyanovsk', 'Europe/Volgograd'], + 'code': 'RU', 'name': 'Russia'}, + {'timezones': ['Africa/Kigali'], + 'code': 'RW', 'name': 'Rwanda'}, + {'timezones': ['America/St_Barthelemy'], + 'code': 'BL', 'name': 'Saint Barthelemy'}, + {'timezones': ['Atlantic/St_Helena'], + 'code': 'SH', 'name': 'Saint Helena'}, + {'timezones': ['America/St_Kitts'], + 'code': 'KN', 'name': 'Saint Kitts and Nevis'}, + {'timezones': ['America/St_Lucia'], + 'code': 'LC', 'name': 'Saint Lucia'}, + {'timezones': ['America/Marigot'], + 'code': 'MF', 'name': 'Saint Martin'}, + {'timezones': ['America/Miquelon'], + 'code': 'PM', 'name': 'Saint Pierre and Miquelon'}, + {'timezones': ['America/St_Vincent'], + 'code': 'VC', 'name': 'Saint Vincent and the Grenadines'}, + {'timezones': ['Pacific/Apia'], + 'code': 'WS', 'name': 'Samoa'}, + {'timezones': ['Europe/San_Marino'], + 'code': 'SM', 'name': 'San Marino'}, + {'timezones': ['Africa/Sao_Tome'], + 'code': 'ST', 'name': 'Sao Tome and Principe'}, + {'timezones': ['Asia/Riyadh'], + 'code': 'SA', 'name': 'Saudi Arabia'}, + {'timezones': ['Africa/Dakar'], + 'code': 'SN', 'name': 'Senegal'}, + {'timezones': ['Europe/Belgrade'], + 'code': 'RS', 'name': 'Serbia'}, + {'timezones': ['Indian/Mahe'], + 'code': 'SC', 'name': 'Seychelles'}, + {'timezones': ['Africa/Freetown'], + 'code': 'SL', 'name': 'Sierra Leone'}, + {'timezones': ['Asia/Singapore'], + 'code': 'SG', 'name': 'Singapore'}, + {'timezones': ['America/Lower_Princes'], + 'code': 'SX', 'name': 'Sint Maarten'}, + {'timezones': ['Europe/Bratislava'], + 'code': 'SK', 'name': 'Slovakia'}, + {'timezones': ['Europe/Ljubljana'], + 'code': 'SI', 'name': 'Slovenia'}, + {'timezones': ['Pacific/Guadalcanal'], + 'code': 'SB', 'name': 'Solomon Islands'}, + {'timezones': ['Africa/Mogadishu'], + 'code': 'SO', 'name': 'Somalia'}, + {'timezones': ['Africa/Johannesburg'], + 'code': 'ZA', 'name': 'South Africa'}, + {'timezones': ['Atlantic/South_Georgia'], + 'code': 'GS', + 'name': 'South Georgia and the South Sandwich Islands'}, + {'timezones': ['Asia/Seoul'], + 'code': 'KR', 'name': 'South Korea'}, + {'timezones': ['Africa/Juba'], + 'code': 'SS', 'name': 'South Sudan'}, + {'timezones': [ + 'Africa/Ceuta', + 'Atlantic/Canary', 'Europe/Madrid'], + 'code': 'ES', 'name': 'Spain'}, + {'timezones': ['Asia/Colombo'], + 'code': 'LK', 'name': 'Sri Lanka'}, + {'timezones': ['Africa/Khartoum'], + 'code': 'SD', 'name': 'Sudan'}, + {'timezones': ['America/Paramaribo'], + 'code': 'SR', 'name': 'Suriname'}, + {'timezones': ['Arctic/Longyearbyen'], + 'code': 'SJ', 'name': 'Svalbard and Jan Mayen'}, + {'timezones': ['Africa/Mbabane'], + 'code': 'SZ', 'name': 'Swaziland'}, + {'timezones': ['Europe/Stockholm'], + 'code': 'SE', 'name': 'Sweden'}, + {'timezones': ['Europe/Zurich'], + 'code': 'CH', 'name': 'Switzerland'}, + {'timezones': ['Asia/Damascus'], + 'code': 'SY', 'name': 'Syria'}, + {'timezones': ['Asia/Taipei'], + 'code': 'TW', 'name': 'Taiwan'}, + {'timezones': ['Asia/Dushanbe'], + 'code': 'TJ', 'name': 'Tajikistan'}, + {'timezones': ['Africa/Dar_es_Salaam'], + 'code': 'TZ', 'name': 'Tanzania'}, + {'timezones': ['Asia/Bangkok'], + 'code': 'TH', 'name': 'Thailand'}, + {'timezones': ['Africa/Lome'], + 'code': 'TG', 'name': 'Togo'}, + {'timezones': ['Pacific/Fakaofo'], + 'code': 'TK', 'name': 'Tokelau'}, + {'timezones': ['Pacific/Tongatapu'], + 'code': 'TO', 'name': 'Tonga'}, + {'timezones': ['America/Port_of_Spain'], + 'code': 'TT', 'name': 'Trinidad and Tobago'}, + {'timezones': ['Africa/Tunis'], + 'code': 'TN', 'name': 'Tunisia'}, + {'timezones': ['Europe/Istanbul'], + 'code': 'TR', 'name': 'Turkey'}, + {'timezones': ['Asia/Ashgabat'], + 'code': 'TM', 'name': 'Turkmenistan'}, + {'timezones': ['America/Grand_Turk'], + 'code': 'TC', 'name': 'Turks and Caicos Islands'}, + {'timezones': ['Pacific/Funafuti'], + 'code': 'TV', 'name': 'Tuvalu'}, + {'timezones': ['America/St_Thomas'], + 'code': 'VI', 'name': 'U.S. Virgin Islands'}, + {'timezones': ['Africa/Kampala'], + 'code': 'UG', 'name': 'Uganda'}, + {'timezones': [ + 'Europe/Kiev', + 'Europe/Uzhgorod', 'Europe/Zaporozhye'], + 'code': 'UA', 'name': 'Ukraine'}, + {'timezones': ['Asia/Dubai'], + 'code': 'AE', 'name': 'United Arab Emirates'}, + {'timezones': ['Europe/London'], + 'code': 'GB', 'name': 'United Kingdom'}, + {'timezones': [ + 'America/Adak', + 'America/Anchorage', 'America/Boise', + 'America/Chicago', 'US/Central', + 'America/Denver', 'America/Detroit', + 'US/Michigan', 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', 'America/Juneau', + 'America/Kentucky/Louisville', 'America/Kentucky/Monticello', + 'America/Los_Angeles', 'US/Pacific', 'America/Menominee', + 'America/Metlakatla', 'America/New_York', 'US/Eastern', + 'America/Nome', 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Phoenix', 'America/Sitka', + 'America/Yakutat', 'Pacific/Honolulu'], + 'code': 'US', 'name': 'United States'}, + {'timezones': ['Pacific/Midway', 'Pacific/Wake'], + 'code': 'UM', 'name': 'United States Minor Outlying Islands'}, + {'timezones': ['America/Montevideo'], + 'code': 'UY', 'name': 'Uruguay'}, + {'timezones': ['Asia/Samarkand', 'Asia/Tashkent'], + 'code': 'UZ', 'name': 'Uzbekistan'}, + {'timezones': ['Pacific/Efate'], + 'code': 'VU', 'name': 'Vanuatu'}, + {'timezones': ['Europe/Vatican'], + 'code': 'VA', 'name': 'Vatican'}, + {'timezones': ['America/Caracas'], + 'code': 'VE', 'name': 'Venezuela'}, + {'timezones': ['Asia/Ho_Chi_Minh', 'Asia/Saigon', 'Asia/Hanoi'], + 'code': 'VN', 'name': 'Vietnam'}, + {'timezones': ['Pacific/Wallis'], + 'code': 'WF', 'name': 'Wallis and Futuna'}, + {'timezones': ['Africa/El_Aaiun'], + 'code': 'EH', 'name': 'Western Sahara'}, + {'timezones': ['Asia/Aden'], + 'code': 'YE', 'name': 'Yemen'}, + {'timezones': ['Africa/Lusaka'], + 'code': 'ZM', 'name': 'Zambia'}, + {'timezones': ['Africa/Harare'], + 'code': 'ZW', 'name': 'Zimbabwe'}, + {'timezones': ['Asia/Rangoon', 'Asia/Yangon'], + 'code': 'MM', 'name': 'Myanmar'}, +] diff --git a/app/resources/international_days.json b/app/resources/international_days.json new file mode 100644 index 00000000..1390e56d --- /dev/null +++ b/app/resources/international_days.json @@ -0,0 +1,1832 @@ +[ + { + "day": 1, + "month": 1, + "international_day": "Ring a bell day and Copyright Law day" + }, + { + "day": 2, + "month": 1, + "international_day": "Science Ficyion Day and World introvert day" + }, + { + "day": 3, + "month": 1, + "international_day": "Drinking straw day and Festival of sleep day" + }, + { + "day": 4, + "month": 1, + "international_day": "Trivia Day and Weigh-in day" + }, + { + "day": 5, + "month": 1, + "international_day": "Whipped Cream day and Bird Day" + }, + { + "day": 6, + "month": 1, + "international_day": "Cuddle up day and Bean Day" + }, + { + "day": 7, + "month": 1, + "international_day": "Tempura day and Bobblehead day" + }, + { + "day": 8, + "month": 1, + "international_day": "Babble bath day and Joy Germ day" + }, + { + "day": 9, + "month": 1, + "international_day": "Apricot Day and Balloon Ascension day" + }, + { + "day": 10, + "month": 1, + "international_day": "Peculiar people day and Bittersweet Chocolate day" + }, + { + "day": 11, + "month": 1, + "international_day": "Step in a puddle and splash your friends day and Heritage treasures day" + }, + { + "day": 12, + "month": 1, + "international_day": "Kiss a ginger day and Marzipan day" + }, + { + "day": 13, + "month": 1, + "international_day": "Sticker day and Rubber duckie day" + }, + { + "day": 14, + "month": 1, + "international_day": "Dress up your pet day and International Kite day" + }, + { + "day": 15, + "month": 1, + "international_day": "Hat day and Bagel day" + }, + { + "day": 16, + "month": 1, + "international_day": "Nothong day and Religious freedom day" + }, + { + "day": 17, + "month": 1, + "international_day": "Ditch new year's resolutions day and Kid inventor's day" + }, + { + "day": 18, + "month": 1, + "international_day": "Thesaurus day and Martin luther king day" + }, + { + "day": 19, + "month": 1, + "international_day": "Popcorn day and Tin can day" + }, + { + "day": 20, + "month": 1, + "international_day": "Disc jockey day and Cheese lovers day" + }, + { + "day": 21, + "month": 1, + "international_day": "Hugging day and Playdate day" + }, + { + "day": 22, + "month": 1, + "international_day": "Answer your cat's Questions day and Hot sauce day" + }, + { + "day": 23, + "month": 1, + "international_day": "Pie day and Visit your local quilt shop day" + }, + { + "day": 24, + "month": 1, + "international_day": "Beer can appreciation day and Peanut Butter day" + }, + { + "day": 25, + "month": 1, + "international_day": "Bubble warp appreciation day and Opposite day" + }, + { + "day": 26, + "month": 1, + "international_day": "Australia day and Peanut brittle day" + }, + { + "day": 27, + "month": 1, + "international_day": "Chocolate cake day and World breast pumping day" + }, + { + "day": 28, + "month": 1, + "international_day": "International lego day and Global community engagement day" + }, + { + "day": 29, + "month": 1, + "international_day": "Fun at work day and Puzzle day" + }, + { + "day": 30, + "month": 1, + "international_day": "Inane answering message day and Seed swap day" + }, + { + "day": 31, + "month": 1, + "international_day": "Backward day and Gorilla suit day" + }, + { + "day": 1, + "month": 2, + "international_day": "Baked alaska day and World read aloud day" + }, + { + "day": 2, + "month": 2, + "international_day": "World play your ukulele day and Tater tot day" + }, + { + "day": 3, + "month": 2, + "international_day": "Carrot cake day and Golden retriver day" + }, + { + "day": 4, + "month": 2, + "international_day": "Thank a letter carrier day and World cancer day" + }, + { + "day": 5, + "month": 2, + "international_day": "World nutella day and Weatherperson's day" + }, + { + "day": 6, + "month": 2, + "international_day": "Take your child to the libray day and Frozen yogurt day" + }, + { + "day": 7, + "month": 2, + "international_day": "Yorkshire pudding day and Wava all your fingers at your neighbors day" + }, + { + "day": 8, + "month": 2, + "international_day": "Clean out your computer day and Molasses bar day" + }, + { + "day": 9, + "month": 2, + "international_day": "Pizza day and Safer internet day" + }, + { + "day": 10, + "month": 2, + "international_day": "Umbrella day and Plimsoll day" + }, + { + "day": 11, + "month": 2, + "international_day": "Fat food day and Peppermint patty day" + }, + { + "day": 12, + "month": 2, + "international_day": "Darwin day and No one eats alone day" + }, + { + "day": 13, + "month": 2, + "international_day": "Radio day and Tortellini day" + }, + { + "day": 14, + "month": 2, + "international_day": "Marriage day and Ferris Wheel day" + }, + { + "day": 15, + "month": 2, + "international_day": "Hippo day and Annoy squidward day" + }, + { + "day": 16, + "month": 2, + "international_day": "Innovation day and Tim Tam day" + }, + { + "day": 17, + "month": 2, + "international_day": "Random acts of kindness day and World human spirit day" + }, + { + "day": 18, + "month": 2, + "international_day": "Drink wine day and Pluto day" + }, + { + "day": 19, + "month": 2, + "international_day": "International Tug-of-War day and Chocolate mint day" + }, + { + "day": 20, + "month": 2, + "international_day": "Love your pet day and Pangolin day" + }, + { + "day": 21, + "month": 2, + "international_day": "Sticky bun day and World whale day" + }, + { + "day": 22, + "month": 2, + "international_day": "World thinking day and Single tasking day" + }, + { + "day": 23, + "month": 2, + "international_day": "Curling is cool day and Play tennis day" + }, + { + "day": 24, + "month": 2, + "international_day": "Pink day and Tortilla chip day" + }, + { + "day": 25, + "month": 2, + "international_day": "Toast day and Chilli day" + }, + { + "day": 26, + "month": 2, + "international_day": "Levi Strauss day and Personal chef day" + }, + { + "day": 27, + "month": 2, + "international_day": "Pokemon day and World NGO day" + }, + { + "day": 28, + "month": 2, + "international_day": "Tooth fairy day and Floral Design day" + }, + { + "day": 29, + "month": 2, + "international_day": "Extra day in leap year" + }, + { + "day": 1, + "month": 3, + "international_day": "Barista day and Fun facts about names day" + }, + { + "day": 2, + "month": 3, + "international_day": "Read across america day and Old stuff day" + }, + { + "day": 3, + "month": 3, + "international_day": "World wildlife day and What if cats and dogs had opposable Thumbs day" + }, + { + "day": 4, + "month": 3, + "international_day": "Grammar day and Marching band day" + }, + { + "day": 5, + "month": 3, + "international_day": "Day of unplugging and World book day" + }, + { + "day": 6, + "month": 3, + "international_day": "White chocolate cheesecake day and dentist's day" + }, + { + "day": 7, + "month": 3, + "international_day": "Be heard day and Plant power day" + }, + { + "day": 8, + "month": 3, + "international_day": "International women's day and Peanut cluster day" + }, + { + "day": 9, + "month": 3, + "international_day": "Meatball day and Barbie day" + }, + { + "day": 10, + "month": 3, + "international_day": "Pack your lunch day and International wig day" + }, + { + "day": 11, + "month": 3, + "international_day": "World plumbing day and Oatmeal nut waffles day" + }, + { + "day": 12, + "month": 3, + "international_day": "Girls scout day and International fanny pack day" + }, + { + "day": 13, + "month": 3, + "international_day": "Jewel day and Ken day" + }, + { + "day": 14, + "month": 3, + "international_day": "Learn about butterflies day and Pi day" + }, + { + "day": 15, + "month": 3, + "international_day": "World speech day and World consumer rights day" + }, + { + "day": 16, + "month": 3, + "international_day": "Lips appreciation day and St.urho's day" + }, + { + "day": 17, + "month": 3, + "international_day": "Saint Patrick's day" + }, + { + "day": 18, + "month": 3, + "international_day": "Awkward moments day and Companies that care day" + }, + { + "day": 19, + "month": 3, + "international_day": "World sleep day and Poultry day" + }, + { + "day": 20, + "month": 3, + "international_day": "International day of happines and Quilting day" + }, + { + "day": 21, + "month": 3, + "international_day": "World poetry day and Vermouth day" + }, + { + "day": 22, + "month": 3, + "international_day": "World water day and Gryffindor pride day" + }, + { + "day": 23, + "month": 3, + "international_day": "Melba toast day and Puppy day" + }, + { + "day": 24, + "month": 3, + "international_day": "Chocolate covered raisins day and Flatmates day" + }, + { + "day": 25, + "month": 3, + "international_day": "Waffle day and Tolkien Reading day" + }, + { + "day": 26, + "month": 3, + "international_day": "Good hair day and Purple day" + }, + { + "day": 27, + "month": 3, + "international_day": "Earth hour and International whiskey day" + }, + { + "day": 28, + "month": 3, + "international_day": "Neighbor day and Black forest cake day" + }, + { + "day": 29, + "month": 3, + "international_day": "Lemon chiffon cake day and Niagara falls runs dry day" + }, + { + "day": 30, + "month": 3, + "international_day": "Doctor's day and Take a walk in the park day" + }, + { + "day": 31, + "month": 3, + "international_day": "Crayola Crayon day and World backup day" + }, + { + "day": 1, + "month": 4, + "international_day": "Fun day and Tell a lie day" + }, + { + "day": 2, + "month": 4, + "international_day": "Ferret day and Walk to work day" + }, + { + "day": 3, + "month": 4, + "international_day": "DIY day and Chocolate mousse day" + }, + { + "day": 4, + "month": 4, + "international_day": "Vitamin C day and Geologist's day" + }, + { + "day": 5, + "month": 4, + "international_day": "Star trek first contact day and Read a road map day" + }, + { + "day": 6, + "month": 4, + "international_day": "World table tennis day and New beer's eve" + }, + { + "day": 7, + "month": 4, + "international_day": "Beer day and No housework day" + }, + { + "day": 8, + "month": 4, + "international_day": "Zoo lovers day and Pygmy hippo day" + }, + { + "day": 9, + "month": 4, + "international_day": "Unicorn day and ASMR day" + }, + { + "day": 10, + "month": 4, + "international_day": "Golfer's day and International safety pin day" + }, + { + "day": 11, + "month": 4, + "international_day": "Pet day and Cheese fondue day" + }, + { + "day": 12, + "month": 4, + "international_day": "Deskfast day and Hamster day" + }, + { + "day": 13, + "month": 4, + "international_day": "Scrabble day and Internatinal FND Awareness day" + }, + { + "day": 14, + "month": 4, + "international_day": "Dolphin day and Day of pink" + }, + { + "day": 15, + "month": 4, + "international_day": "Husband Appriciations day and High five day" + }, + { + "day": 16, + "month": 4, + "international_day": "Wear your pajamas to work day and Save the elephant day" + }, + { + "day": 17, + "month": 4, + "international_day": "Haiku poetry day and Blah blah blah day" + }, + { + "day": 18, + "month": 4, + "international_day": "Pinata day and Columnists day" + }, + { + "day": 19, + "month": 4, + "international_day": "Bicycle day and Hanging out day" + }, + { + "day": 20, + "month": 4, + "international_day": "Volunteer recognition day and Chinese language day" + }, + { + "day": 21, + "month": 4, + "international_day": "World creativity and innovation day and World stationery day" + }, + { + "day": 22, + "month": 4, + "international_day": "Teach your children to save day and Earth day" + }, + { + "day": 23, + "month": 4, + "international_day": "Talk like Shakespeare day and Asparagus day" + }, + { + "day": 24, + "month": 4, + "international_day": "Scream day and Pig in a blanket day" + }, + { + "day": 25, + "month": 4, + "international_day": "Pinhole photography day and Hug a plumber day" + }, + { + "day": 26, + "month": 4, + "international_day": "Hug an australian day and Burlesque day" + }, + { + "day": 27, + "month": 4, + "international_day": "Morse code day and Tell a story day" + }, + { + "day": 28, + "month": 4, + "international_day": "Superhero day and Stop food waste day" + }, + { + "day": 29, + "month": 4, + "international_day": "International dance day and We jump the world day" + }, + { + "day": 30, + "month": 4, + "international_day": "Hairball awareness day and honesty day" + }, + { + "day": 1, + "month": 5, + "international_day": "Tuba day and Therapeutic massage awareness day" + }, + { + "day": 2, + "month": 5, + "international_day": "World laughter day and Baby day" + }, + { + "day": 3, + "month": 5, + "international_day": "Lemonade day and Garden meditation day" + }, + { + "day": 4, + "month": 5, + "international_day": "Star wars day and 45 day" + }, + { + "day": 5, + "month": 5, + "international_day": "Nail day and Internatinal midwive's day" + }, + { + "day": 6, + "month": 5, + "international_day": "No diet day and Password day" + }, + { + "day": 7, + "month": 5, + "international_day": "Roast leg of lamb day and Public gardens day" + }, + { + "day": 8, + "month": 5, + "international_day": "Windmill day and No socks day" + }, + { + "day": 9, + "month": 5, + "international_day": "Moscato day and Lost sock memorial day" + }, + { + "day": 10, + "month": 5, + "international_day": "Mother ocean day and Stay up all night night" + }, + { + "day": 11, + "month": 5, + "international_day": "Eat what you want day and World ego awareness day" + }, + { + "day": 12, + "month": 5, + "international_day": "Receptionist's day and Limerick day" + }, + { + "day": 13, + "month": 5, + "international_day": "Numeracy day and Top gun day" + }, + { + "day": 14, + "month": 5, + "international_day": "Shades day and Chicken dance day" + }, + { + "day": 15, + "month": 5, + "international_day": "World whisky day and Chocolate chip day" + }, + { + "day": 16, + "month": 5, + "international_day": "Drawing day and Sea monkey day" + }, + { + "day": 17, + "month": 5, + "international_day": "Work from home day and International Day Against Homophobia and Transphobia and Biphobia" + }, + { + "day": 18, + "month": 5, + "international_day": "No dirty dishes day and Museum day" + }, + { + "day": 19, + "month": 5, + "international_day": "May ray day" + }, + { + "day": 20, + "month": 5, + "international_day": "Pick strawberries day and World bee day" + }, + { + "day": 21, + "month": 5, + "international_day": "World meditation day and I need a patch for that day" + }, + { + "day": 22, + "month": 5, + "international_day": "Sherlock Holmes day and Goth day" + }, + { + "day": 23, + "month": 5, + "international_day": "Turtle day and Lucky penny day" + }, + { + "day": 24, + "month": 5, + "international_day": "Tiara day and Escargot day" + }, + { + "day": 25, + "month": 5, + "international_day": "Tap dance day and Towel day" + }, + { + "day": 26, + "month": 5, + "international_day": "Senior health and fitness day and Paper airplane day" + }, + { + "day": 27, + "month": 5, + "international_day": "Sun screen day and World product day" + }, + { + "day": 28, + "month": 5, + "international_day": "Amnesty international day and Hamburger day" + }, + { + "day": 29, + "month": 5, + "international_day": "Biscuit day and Paper clip day" + }, + { + "day": 30, + "month": 5, + "international_day": "Mint julep day and Water a flower day" + }, + { + "day": 31, + "month": 5, + "international_day": "No tabbaco day and Save your hearing day" + }, + { + "day": 1, + "month": 6, + "international_day": "Say something nice day" + }, + { + "day": 2, + "month": 6, + "international_day": "Rocky road day and Running day" + }, + { + "day": 3, + "month": 6, + "international_day": "World bicycle day and Chocolate maccaroon day" + }, + { + "day": 4, + "month": 6, + "international_day": "Hug your cat day and Doughnut day" + }, + { + "day": 5, + "month": 6, + "international_day": "Sausage roll day and Coworking day" + }, + { + "day": 6, + "month": 6, + "international_day": "Gardening exercise day and Cancer survivors day" + }, + { + "day": 7, + "month": 6, + "international_day": "Chocolate ice cream day and VCR day" + }, + { + "day": 8, + "month": 6, + "international_day": "Best friends day and World oceans day" + }, + { + "day": 9, + "month": 6, + "international_day": "Rosé day and Donald duck day" + }, + { + "day": 10, + "month": 6, + "international_day": "Iced tea day and Jerky day" + }, + { + "day": 11, + "month": 6, + "international_day": "Yarn Bombing day and Corn of the cob day" + }, + { + "day": 12, + "month": 6, + "international_day": "Superman day and World gin day" + }, + { + "day": 13, + "month": 6, + "international_day": "Sewing machine day and World softball day" + }, + { + "day": 14, + "month": 6, + "international_day": "World blood donor day and Flag day" + }, + { + "day": 15, + "month": 6, + "international_day": "Nature photography day and Beer day Britain" + }, + { + "day": 16, + "month": 6, + "international_day": "World tapas day and Fresh Veggies day" + }, + { + "day": 17, + "month": 6, + "international_day": "Eat your vegetables day and Garbage man day" + }, + { + "day": 18, + "month": 6, + "international_day": "International picnic day and Go fishing day" + }, + { + "day": 19, + "month": 6, + "international_day": "Martini day and Juggling day" + }, + { + "day": 20, + "month": 6, + "international_day": "Ice cream soda day and World refugee day" + }, + { + "day": 21, + "month": 6, + "international_day": "World music day and International yoga day" + }, + { + "day": 22, + "month": 6, + "international_day": "World rainforest day and Onion rings day" + }, + { + "day": 23, + "month": 6, + "international_day": "Let it go day and International widows day" + }, + { + "day": 24, + "month": 6, + "international_day": "Bomb pop day and Swim in lap day" + }, + { + "day": 25, + "month": 6, + "international_day": "Global beatles day and Take your dog to work day" + }, + { + "day": 26, + "month": 6, + "international_day": "Blueberry cheesecake day and Beautician's day and World refrigeration day" + }, + { + "day": 27, + "month": 6, + "international_day": "Pineapple day and Sunglasses day" + }, + { + "day": 28, + "month": 6, + "international_day": "International body piercing day and Logistics day" + }, + { + "day": 29, + "month": 6, + "international_day": "Waffle iron day and Camera day" + }, + { + "day": 30, + "month": 6, + "international_day": "Seocial media day and Metheor watch day" + }, + { + "day": 1, + "month": 7, + "international_day": "Joke day and Gingersnap day" + }, + { + "day": 2, + "month": 7, + "international_day": "Anisette day and World UFO day" + }, + { + "day": 3, + "month": 7, + "international_day": "Air conditioning appreciation day and Eat beans day" + }, + { + "day": 4, + "month": 7, + "international_day": "Independence from meat day and Jackfruit day" + }, + { + "day": 5, + "month": 7, + "international_day": "Apple turnover day and Bikini day" + }, + { + "day": 6, + "month": 7, + "international_day": "International kissing day and Fried chicken day" + }, + { + "day": 7, + "month": 7, + "international_day": "Chocolate day and Macaroni day" + }, + { + "day": 8, + "month": 7, + "international_day": "Math 2.0 day and Chocolate with almonds day" + }, + { + "day": 9, + "month": 7, + "international_day": "Kebab day and Sugar cookie day" + }, + { + "day": 10, + "month": 7, + "international_day": "Pina colada day and Teddy bear picnic day" + }, + { + "day": 11, + "month": 7, + "international_day": "Blueberry muffin day and World population day" + }, + { + "day": 12, + "month": 7, + "international_day": "Etch a sketch day and New conversations day" + }, + { + "day": 13, + "month": 7, + "international_day": "French fries day and Cow appreciation day" + }, + { + "day": 14, + "month": 7, + "international_day": "Shark awareness day and Mac & cheese day" + }, + { + "day": 15, + "month": 7, + "international_day": "Gummi worm day and Hot dog day" + }, + { + "day": 16, + "month": 7, + "international_day": "Guinea pig appreciation day and Corn fritters day" + }, + { + "day": 17, + "month": 7, + "international_day": "World emoji day and Peach ice cream day" + }, + { + "day": 18, + "month": 7, + "international_day": "Caviar day and Insurance nerd day" + }, + { + "day": 19, + "month": 7, + "international_day": "Daiquiri day and Get out of the doghouse day" + }, + { + "day": 20, + "month": 7, + "international_day": "Moon day and International chess day" + }, + { + "day": 21, + "month": 7, + "international_day": "Junk food day and Lamington day" + }, + { + "day": 22, + "month": 7, + "international_day": "Hammock day and Crème brulee day" + }, + { + "day": 23, + "month": 7, + "international_day": "Peanut butter and chocolate day and Sprinkle day" + }, + { + "day": 24, + "month": 7, + "international_day": "Drive-thru day and Tequila day" + }, + { + "day": 25, + "month": 7, + "international_day": "Wine and cheese day and Parent's day" + }, + { + "day": 26, + "month": 7, + "international_day": "Aunt and uncle day and Coffee milk shake day" + }, + { + "day": 27, + "month": 7, + "international_day": "Walk on stilts day and Norfolk day" + }, + { + "day": 28, + "month": 7, + "international_day": "Milk chocolate day and World hepatitis day" + }, + { + "day": 29, + "month": 7, + "international_day": "Chili dog day and International tiger day" + }, + { + "day": 30, + "month": 7, + "international_day": "Cheesecake day and Friendship day" + }, + { + "day": 31, + "month": 7, + "international_day": "Raspberry cake day and Uncommon Instrument awareness day" + }, + { + "day": 1, + "month": 8, + "international_day": "Sisters day and Planner day" + }, + { + "day": 2, + "month": 8, + "international_day": "Ice cream sandwich day and Coloring book day" + }, + { + "day": 3, + "month": 8, + "international_day": "Watermelon day and White wine day" + }, + { + "day": 4, + "month": 8, + "international_day": "Coast guard day and International clouded leopard day" + }, + { + "day": 5, + "month": 8, + "international_day": "Work like a dog day and Blogger day" + }, + { + "day": 6, + "month": 8, + "international_day": "Fresh breath day and International beer day" + }, + { + "day": 7, + "month": 8, + "international_day": "Particularly preposterous packing day and Aged care employee day" + }, + { + "day": 8, + "month": 8, + "international_day": "International cat day and Happiness happens day" + }, + { + "day": 9, + "month": 8, + "international_day": "Melon day and Rice pudding day" + }, + { + "day": 10, + "month": 8, + "international_day": "Lazy day and World lion day" + }, + { + "day": 11, + "month": 8, + "international_day": "World calligraphy day and Son and daughter day" + }, + { + "day": 12, + "month": 8, + "international_day": "World Elephant day and Vinyl record day" + }, + { + "day": 13, + "month": 8, + "international_day": "Blame someone else day and International lefthanders day" + }, + { + "day": 14, + "month": 8, + "international_day": "Creamsicle day and Tattoo removal day" + }, + { + "day": 15, + "month": 8, + "international_day": "Check the chip day and Relaxation day" + }, + { + "day": 16, + "month": 8, + "international_day": "Rollercoaster day and Rum day" + }, + { + "day": 17, + "month": 8, + "international_day": "Thrift shop day and Vanilla custard day" + }, + { + "day": 18, + "month": 8, + "international_day": "Bad poetry day and Never give up day" + }, + { + "day": 19, + "month": 8, + "international_day": "International orangutan day and Photography day" + }, + { + "day": 20, + "month": 8, + "international_day": "Men's grooming day and International day of medical transporters" + }, + { + "day": 21, + "month": 8, + "international_day": "Senior citizen day and World honey bee day" + }, + { + "day": 22, + "month": 8, + "international_day": "Be an angel day and Eat a peach day" + }, + { + "day": 23, + "month": 8, + "international_day": "Cuban sandwich day and Ride the wind day" + }, + { + "day": 24, + "month": 8, + "international_day": "International strange music day and Knife day" + }, + { + "day": 25, + "month": 8, + "international_day": "Kiss and make up day and Banana split day" + }, + { + "day": 26, + "month": 8, + "international_day": "Burger day and Dog day" + }, + { + "day": 27, + "month": 8, + "international_day": "International bat night and Banana lovers day" + }, + { + "day": 28, + "month": 8, + "international_day": "Bow tie day and Franchise appreciation day" + }, + { + "day": 29, + "month": 8, + "international_day": "More herbs less salt day and Potteries bottle oven day" + }, + { + "day": 30, + "month": 8, + "international_day": "Slinky day and Amagwinya day" + }, + { + "day": 31, + "month": 8, + "international_day": "Trail mix day and Overdose awareness day" + }, + { + "day": 1, + "month": 9, + "international_day": "Building and code staff appreciation day and Tofu day" + }, + { + "day": 2, + "month": 9, + "international_day": "Calendar adjustment day and V-J day" + }, + { + "day": 3, + "month": 9, + "international_day": "Skyscraper day and Bring your manners to work day" + }, + { + "day": 4, + "month": 9, + "international_day": "Wildlife day and Macadamia nut day" + }, + { + "day": 5, + "month": 9, + "international_day": "Be late for something day and World samosa day" + }, + { + "day": 6, + "month": 9, + "international_day": "Read a book day and Mouthguard day" + }, + { + "day": 7, + "month": 9, + "international_day": "World duchenne awareness day and Beer lover's day" + }, + { + "day": 8, + "month": 9, + "international_day": "Star terk day and Pardon day" + }, + { + "day": 9, + "month": 9, + "international_day": "Teddy bear day and Wienerschnitzel day" + }, + { + "day": 10, + "month": 9, + "international_day": "World suicide prevention day and TV dinner day" + }, + { + "day": 11, + "month": 9, + "international_day": "Make your bed day and Drive your studebaker day" + }, + { + "day": 12, + "month": 9, + "international_day": "Video games day and Hug your hound day" + }, + { + "day": 13, + "month": 9, + "international_day": "Fortune cookie day and Boss/Employee exchange day" + }, + { + "day": 14, + "month": 9, + "international_day": "Eat a hoagie day and Cream filled doughnut day" + }, + { + "day": 15, + "month": 9, + "international_day": "World afro day and Cheese toast day" + }, + { + "day": 16, + "month": 9, + "international_day": "Guacamole day and Play doh day" + }, + { + "day": 17, + "month": 9, + "international_day": "Tradesmen day and International country music day" + }, + { + "day": 18, + "month": 9, + "international_day": "International red panda day and Cheeseburger day" + }, + { + "day": 19, + "month": 9, + "international_day": "Talk like a priate day and Butterscotch pudding day" + }, + { + "day": 20, + "month": 9, + "international_day": "Punch day and Pepperoni pizza day" + }, + { + "day": 21, + "month": 9, + "international_day": "World alzheimer's day and Escapology day" + }, + { + "day": 22, + "month": 9, + "international_day": "Business women's day and World car free day" + }, + { + "day": 23, + "month": 9, + "international_day": "Restless legs awareness day and Za'atar day and Fitness day" + }, + { + "day": 24, + "month": 9, + "international_day": "Hug a vegetarian day and Lash stylist's day" + }, + { + "day": 25, + "month": 9, + "international_day": "World dream day" + }, + { + "day": 26, + "month": 9, + "international_day": "Lumberjack day and Rivers day" + }, + { + "day": 27, + "month": 9, + "international_day": "Tourism day and Corned beef hash day" + }, + { + "day": 28, + "month": 9, + "international_day": "Drink beer day and International poke day" + }, + { + "day": 29, + "month": 9, + "international_day": "World heart day and Biscotti day" + }, + { + "day": 30, + "month": 9, + "international_day": "Ask a stupid question day and International podcast day" + }, + { + "day": 1, + "month": 10, + "international_day": "World smile day and International coffee day" + }, + { + "day": 2, + "month": 10, + "international_day": "Name your car day and World farm animals day" + }, + { + "day": 3, + "month": 10, + "international_day": "Techies day and Boyfriend's day" + }, + { + "day": 4, + "month": 10, + "international_day": "Vodka day and World habitat day" + }, + { + "day": 5, + "month": 10, + "international_day": "World teachers day and Chic spy day" + }, + { + "day": 6, + "month": 10, + "international_day": "Canadian beer day and Mad hatter day" + }, + { + "day": 7, + "month": 10, + "international_day": "Bathtub day and Frappe day" + }, + { + "day": 8, + "month": 10, + "international_day": "World Octopus day and Egg day" + }, + { + "day": 9, + "month": 10, + "international_day": "Scrubs day and Beer and pizza day" + }, + { + "day": 10, + "month": 10, + "international_day": "Hug a drummer day and SHIFT10 day" + }, + { + "day": 11, + "month": 10, + "international_day": "Coming out day and Canadian thanksgiving" + }, + { + "day": 12, + "month": 10, + "international_day": "Old farmers day and Own business day" + }, + { + "day": 13, + "month": 10, + "international_day": "No bra day and Train your brain day" + }, + { + "day": 14, + "month": 10, + "international_day": "Dessert day and International top spinning day" + }, + { + "day": 15, + "month": 10, + "international_day": "World student's day and Chicken cacciatore day" + }, + { + "day": 16, + "month": 10, + "international_day": "World food day and Dictionary day" + }, + { + "day": 17, + "month": 10, + "international_day": "Toy camera day and Spreadsheet day" + }, + { + "day": 18, + "month": 10, + "international_day": "Chocolate cupcake day and Developmental language disorder awareness day" + }, + { + "day": 19, + "month": 10, + "international_day": "Evaluate your life day and International gin and tonic day" + }, + { + "day": 20, + "month": 10, + "international_day": "International chef day and International sloth day" + }, + { + "day": 21, + "month": 10, + "international_day": "Apple day and Get smart about cerdit day" + }, + { + "day": 22, + "month": 10, + "international_day": "Caps lock day and International stuttering awareness day" + }, + { + "day": 23, + "month": 10, + "international_day": "Make a difference day and Ipod day" + }, + { + "day": 24, + "month": 10, + "international_day": "Unites nation day and Mother in law day" + }, + { + "day": 25, + "month": 10, + "international_day": "International artist day and Accounting day" + }, + { + "day": 26, + "month": 10, + "international_day": "Howl at the moon day and Pumpkin day" + }, + { + "day": 27, + "month": 10, + "international_day": "Black cat day and Cranky co-workers day" + }, + { + "day": 28, + "month": 10, + "international_day": "Plush animal lover's day" + }, + { + "day": 29, + "month": 10, + "international_day": "Animation day and Internet day and Cat day" + }, + { + "day": 30, + "month": 10, + "international_day": "Checklist day and Hug a sheep day" + }, + { + "day": 31, + "month": 10, + "international_day": "Magic day and Caramel apple day" + }, + { + "day": 1, + "month": 11, + "international_day": "World vegan day and Go cook for your pets day" + }, + { + "day": 2, + "month": 11, + "international_day": "Deviled egg day and Dynamic harmlessness day" + }, + { + "day": 3, + "month": 11, + "international_day": "Stress awareness day and Sandwich day" + }, + { + "day": 4, + "month": 11, + "international_day": "Use your common sense day and Men make dinner day" + }, + { + "day": 5, + "month": 11, + "international_day": "Love your red hair day and Love your lawyer day" + }, + { + "day": 6, + "month": 11, + "international_day": "Nachos day and Numbat day" + }, + { + "day": 7, + "month": 11, + "international_day": "Bittersweet chocolate with almonds day and Zero tasking day" + }, + { + "day": 8, + "month": 11, + "international_day": "World orphans day and World quality day" + }, + { + "day": 9, + "month": 11, + "international_day": "World freedom day and Chaos never dies day" + }, + { + "day": 10, + "month": 11, + "international_day": "Sesame street day and Top up day" + }, + { + "day": 11, + "month": 11, + "international_day": "Origami day and Sundae day" + }, + { + "day": 12, + "month": 11, + "international_day": "Happy hour day and Chicken soup for the soul day" + }, + { + "day": 13, + "month": 11, + "international_day": "World kindness day and Indian pudding day" + }, + { + "day": 14, + "month": 11, + "international_day": "Operating room nurse day and Tongue twister day" + }, + { + "day": 15, + "month": 11, + "international_day": "Clean out your refigerator day and Bundt cake day" + }, + { + "day": 16, + "month": 11, + "international_day": "Have a party with your bear day and Clarinet day" + }, + { + "day": 17, + "month": 11, + "international_day": "Homemade bread day and Unfriend day" + }, + { + "day": 18, + "month": 11, + "international_day": "Housing day and Social enterprise day" + }, + { + "day": 19, + "month": 11, + "international_day": "International men's day and World toilet day" + }, + { + "day": 20, + "month": 11, + "international_day": "Name your PC day and Universal children's day" + }, + { + "day": 21, + "month": 11, + "international_day": "World television day and Red mitten day" + }, + { + "day": 22, + "month": 11, + "international_day": "Go for a ride day and Cranberry relish day" + }, + { + "day": 23, + "month": 11, + "international_day": "Espresso day and Fibonacci day" + }, + { + "day": 24, + "month": 11, + "international_day": "Sardines day and Jukebox day" + }, + { + "day": 25, + "month": 11, + "international_day": "Shopping reminder day and Parfait day" + }, + { + "day": 26, + "month": 11, + "international_day": "Buy nothing day and Flossing day" + }, + { + "day": 27, + "month": 11, + "international_day": "Bavarian cream pie day and Pins and Needles day" + }, + { + "day": 28, + "month": 11, + "international_day": "French toast day and Aura awareness day" + }, + { + "day": 29, + "month": 11, + "international_day": "Chocolates day and Lemon cream pie day" + }, + { + "day": 30, + "month": 11, + "international_day": "Computer security day and Mousse day" + }, + { + "day": 1, + "month": 12, + "international_day": "Eat a red apple day and Day without art day" + }, + { + "day": 2, + "month": 12, + "international_day": "Fritters day" + }, + { + "day": 3, + "month": 12, + "international_day": "Bartender appreciation day and Make a gift day" + }, + { + "day": 4, + "month": 12, + "international_day": "Cookie day and International cheetah day" + }, + { + "day": 5, + "month": 12, + "international_day": "International ninja day and Repeal day" + }, + { + "day": 6, + "month": 12, + "international_day": "Miner's day and Walt disney day" + }, + { + "day": 7, + "month": 12, + "international_day": "Cotton candy day and Pearl harbor remembrance day" + }, + { + "day": 8, + "month": 12, + "international_day": "Brownie day and Lard day" + }, + { + "day": 9, + "month": 12, + "international_day": "Pastry day and Techno day" + }, + { + "day": 10, + "month": 12, + "international_day": "Human rights day and Lager day" + }, + { + "day": 11, + "month": 12, + "international_day": "Have a bagel day and Noodle ring day" + }, + { + "day": 12, + "month": 12, + "international_day": "Poinsettia day and Gingerbread house day" + }, + { + "day": 13, + "month": 12, + "international_day": "Violin day and Day of the horse" + }, + { + "day": 14, + "month": 12, + "international_day": "Monkey day and Roast chestnuts day" + }, + { + "day": 15, + "month": 12, + "international_day": "Cat herders day and Lemon cupcake day" + }, + { + "day": 16, + "month": 12, + "international_day": "Re-gifting day and Chocolate covered anything day" + }, + { + "day": 17, + "month": 12, + "international_day": "Ugly christmas sweater day and Maple syrup day" + }, + { + "day": 18, + "month": 12, + "international_day": "Bake cookies day and Roast suckling pig day" + }, + { + "day": 19, + "month": 12, + "international_day": "Look for an evergreen day and Oatmuffin day" + }, + { + "day": 20, + "month": 12, + "international_day": "Go caroling day and Games day" + }, + { + "day": 21, + "month": 12, + "international_day": "Humbug day and Flashlight day" + }, + { + "day": 22, + "month": 12, + "international_day": "Date nut bread day and Forefather's day" + }, + { + "day": 23, + "month": 12, + "international_day": "Roots day and Festivus day" + }, + { + "day": 24, + "month": 12, + "international_day": "Eggnog day" + }, + { + "day": 25, + "month": 12, + "international_day": "Pumpkin pie day" + }, + { + "day": 26, + "month": 12, + "international_day": "Thank you note day and Candy cane day" + }, + { + "day": 27, + "month": 12, + "international_day": "Make cut-out snowflakes day and Fruitcake day" + }, + { + "day": 28, + "month": 12, + "international_day": "Card playing day" + }, + { + "day": 29, + "month": 12, + "international_day": "Tick Tock day and Pepper pot day" + }, + { + "day": 30, + "month": 12, + "international_day": "Bacon day and Bicarbonate of soda day" + }, + { + "day": 31, + "month": 12, + "international_day": "Make up your mind day and Champagne day" + } +] \ No newline at end of file diff --git a/app/resources/jokes.json b/app/resources/jokes.json new file mode 100644 index 00000000..4950d6c3 --- /dev/null +++ b/app/resources/jokes.json @@ -0,0 +1,368 @@ +[ + {"text": "Chuck Norris uses ribbed condoms inside out, so he gets the pleasure."}, + {"text": "MacGyver can build an airplane out of gum and paper clips. Chuck Norris can kill him and take it."}, + {"text": "Chuck Norris doesn't read books. He stares them down until he gets the information he wants."}, + {"text": "Chuck Norris lost his virginity before his dad did."}, + {"text": "Since 1940, the year Chuck Norris was born, roundhouse kick related deaths have increased 13,000 percent."}, + {"text": "Chuck Norris sheds his skin twice a year."}, + {"text": "When Chuck Norris goes to donate blood, he declines the syringe, and instead requests a hand gun and a bucket."}, + {"text": "Chuck Norris does not teabag the ladies. He potato-sacks them."}, + {"text": "In an average living room there are 1,242 objects Chuck Norris could use to kill you, including the room itself."}, + {"text": "Chuck Norris doesn't shower, he only takes blood baths."}, + {"text": "Time waits for no man. Unless that man is Chuck Norris."}, + {"text": "In the Bible, Jesus turned water into wine. But then Chuck Norris turned that wine into beer."}, + {"text": "Chuck Norris is not hung like a horse. Horses are hung like Chuck Norris."}, + {"text": "Chuck Norris has two speeds: Walk and Kill."}, + {"text": "Fool me once, shame on you. Fool Chuck Norris once and he will roundhouse kick you in the face."}, + {"text": "If you spell Chuck Norris in Scrabble, you win. Forever."}, + {"text": "Someone once videotaped Chuck Norris getting pissed off. It was called Walker: Texas Chain Saw Masacre."}, + {"text": "Chuck Norris will attain statehood in 2009. His state flower will be the Magnolia."}, + {"text": "Chuck Norris doesn't wash his clothes. He disembowels them."}, + {"text": "Chuck Norris doesn't churn butter. He roundhouse kicks the cows and the butter comes straight out."}, + {"text": "Police label anyone attacking Chuck Norris as a Code 45-11.... A suicide."}, + {"text": "Chuck Norris is the only man to ever defeat a brick wall in a game of tennis."}, + {"text": "What was going through the minds of all of Chuck Norris' victims before they died? His shoe."}, + {"text": "Chuck Norris once ate three 72 oz. steaks in one hour. He spent the first 45 minutes having sex with his waitress."}, + {"text": "There is no theory of evolution, just a list of creatures Chuck Norris allows to live."}, + {"text": "Chuck Norris can win a game of Connect Four in only three moves."}, + {"text": "The quickest way to a man's heart is with Chuck Norris' fist."}, + {"text": "Chuck Norris drives an ice cream truck covered in human skulls."}, + {"text": "Most people have 23 pairs of chromosomes. Chuck Norris has 72... and they're all poisonous."}, + {"text": "The Great Wall of China was originally created to keep Chuck Norris out. It failed miserably."}, + {"text": "Chuck Norris is ten feet tall, weighs two-tons, breathes fire, and could eat a hammer and take a shotgun blast standing."}, + {"text": "Crop circles are Chuck Norris' way of telling the world that sometimes corn needs to lie down."}, + {"text": "When Chuck Norris calls 1-900 numbers, he doesn't get charged. He holds up the phone and money falls out."}, + {"text": "Chuck Norris once ate a whole cake before his friends could tell him there was a stripper in it."}, + {"text": "Some people like to eat frogs' legs. Chuck Norris likes to eat lizard legs. Hence, snakes."}, + {"text": "There are no races, only countries of people Chuck Norris has beaten to different shades of black and blue."}, + {"text": "A Chuck Norris-delivered Roundhouse Kick is the preferred method of execution in 16 states."}, + {"text": "When Chuck Norris falls in water, Chuck Norris doesn't get wet. Water gets Chuck Norris."}, + {"text": "Chuck Norris' house has no doors, only walls that he walks through."}, + {"text": "When Chuck Norris has sex with a man, it won't be because he is gay. It will be because he has run out of women."}, + {"text": "How much wood would a woodchuck chuck if a woodchuck could Chuck Norris? All of it."}, + {"text": "Chuck Norris doesn't actually write books, the words assemble themselves out of fear."}, + {"text": "Chuck Norris can believe it's not butter."}, + {"text": "If tapped, a Chuck Norris roundhouse kick could power the country of Australia for 44 minutes."}, + {"text": "Chuck Norris can divide by zero."}, + {"text": "Chuck Norris invented his own type of karate. It's called Chuck-Will-Kill."}, + {"text": "While urinating, Chuck Norris is easily capable of welding titanium."}, + {"text": "When Chuck Norris talks, everybody listens. And dies."}, + {"text": "When Steven Seagal kills a ninja, he only takes its hide. When Chuck Norris kills a ninja, he uses every part."}, + {"text": "Contrary to popular belief, there is indeed enough Chuck Norris to go around."}, + {"text": "Chuck Norris doesnt shave; he kicks himself in the face. The only thing that can cut Chuck Norris is Chuck Norris."}, + {"text": "For some, the left testicle is larger than the right one. For Chuck Norris, each testicle is larger than the other one."}, + {"text": "Chuck Norris always knows the EXACT location of Carmen SanDiego."}, + {"text": "Chuck Norris invented black. In fact, he invented the entire spectrum of visible light. Except pink. Tom Cruise invented pink."}, + {"text": "When you're Chuck Norris, anything + anything is equal to 1. One roundhouse kick to the face."}, + {"text": "On his birthday, Chuck Norris randomly selects one lucky child to be thrown into the sun."}, + {"text": "Nobody doesn't like Sara Lee. Except Chuck Norris."}, + {"text": "Chuck Norris doesn't throw up if he drinks too much. Chuck Norris throws down!"}, + {"text": "Chuck Norris has 12 moons. One of those moons is the Earth."}, + {"text": "Chuck Norris grinds his coffee with his teeth and boils the water with his own rage."}, + {"text": "Chuck Norris ordered a Big Mac at Burger King, and got one."}, + {"text": "Chuck Norris can drink an entire gallon of milk in thirty-seven seconds."}, + {"text": "Chuck Norris doesn't bowl strikes, he just knocks down one pin and the other nine faint."}, + {"text": "It takes Chuck Norris 20 minutes to watch 60 Minutes."}, + {"text": "Chuck Norris has a deep and abiding respect for human life... unless it gets in his way."}, + {"text": "The Bermuda Triangle used to be the Bermuda Square, until Chuck Norris Roundhouse kicked one of the corners off."}, + {"text": "Chuck Norris doesn't believe in Germany."}, + {"text": "When Chuck Norris is in a crowded area, he doesn't walk around people. He walks through them."}, + {"text": "Chuck Norris once ate an entire bottle of sleeping pills. They made him blink."}, + {"text": "Chuck Norris can touch MC Hammer."}, + {"text": "Chuck Norris played Russian Roulette with a fully loaded gun and won."}, + {"text": "It takes 14 puppeteers to make Chuck Norris smile, but only 2 to make him destroy an orphanage."}, + {"text": "Some people wear Superman pajamas. Superman wears Chuck Norris pajamas."}, + {"text": "Simply by pulling on both ends, Chuck Norris can stretch diamonds back into coal."}, + {"text": "When Chuck Norris does a pushup, he isn't lifting himself up, he's pushing the Earth down."}, + {"text": "Chuck Norris invented the bolt-action rifle, liquor, sexual intercourse, and football-- in that order."}, + {"text": "A high tide means Chuck Norris is flying over your coast. The tide is caused by God pissing his pants."}, + {"text": "There is in fact an 'I' in Norris, but there is no 'team'. Not even close."}, + {"text": "An anagram for Walker Texas Ranger is KARATE WRANGLER SEX. I don't know what that is, but it sounds AWESOME."}, + {"text": "Chuck Norris doesn't stub his toes. He accidentally destroys chairs, bedframes, and sidewalks."}, + {"text": "Chuck Norris does not own a stove, oven, or microwave , because revenge is a dish best served cold."}, + {"text": "Chuck Norris can slam a revolving door."}, + {"text": "Chuck Norris built a better mousetrap, but the world was too frightened to beat a path to his door."}, + {"text": "Hellen Keller's favorite color is Chuck Norris."}, + {"text": "If, by some incredible space-time paradox, Chuck Norris would ever fight himself, he'd win. Period."}, + {"text": "Chuck Norris is currently suing myspace for taking the name of what he calls everything around you."}, + {"text": "Science Fact: Roundhouse kicks are comprised primarily of an element called Chucktanium."}, + {"text": "Chuck Norris proved that we are alone in the universe. We weren't before his first space expedition."}, + {"text": "Superman once watched an episode of Walker, Texas Ranger. He then cried himself to sleep."}, + {"text": "Chuck Norris doesn't step on toes. Chuck Norris steps on necks."}, + {"text": "There is no such thing as global warming. Chuck Norris was cold, so he turned the sun up."}, + {"text": "A study showed the leading causes of death in the United States are: 1. Heart disease, 2. Chuck Norris, 3. Cancer"}, + {"text": "Chuck Norris did in fact, build Rome in a day."}, + {"text": "Along with his black belt, Chuck Norris often chooses to wear brown shoes. No one has DARED call him on it. Ever."}, + {"text": "Once you go Norris, you are physically unable to go back."}, + {"text": "Ninjas want to grow up to be just like Chuck Norris. But usually they grow up just to be killed by Chuck Norris."}, + {"text": "The last thing you hear before Chuck Norris gives you a roundhouse kick? No one knows because dead men tell no tales."}, + {"text": "Chuck Norris doesn't play god. Playing is for children."}, + {"text": "Chuck Norris is the only person in the world that can actually email a roundhouse kick."}, + {"text": "Chuck Norris won super bowls VII and VIII singlehandedly before unexpectedly retiring to pursue a career in ass-kicking."}, + {"text": "Chuck Norris can set ants on fire with a magnifying glass. At night."}, + {"text": "Some kids play Kick the can. Chuck Norris played Kick the keg."}, + {"text": "'Icy-Hot' is too weak for Chuck Norris. After a workout, Chuck Norris rubs his muscles down with liquid-hot MAGMA."}, + {"text": "Chuck Norris cannot love, he can only not kill."}, + {"text": "When Chuck Norris was a baby, he didn't suck his mother's breast. His mother served him whiskey, straight out of the bottle."}, + {"text": "According to Einstein's theory of relativity, Chuck Norris can actually roundhouse kick you yesterday."}, + {"text": "Chuck Norris once pulled out a single hair from his beard and skewered three men through the heart with it."}, + {"text": "Chuck Norris? favourite cut of meat is the roundhouse."}, + {"text": "Chuck Norris recently had the idea to sell his urine as a canned beverage. We know this beverage as Red Bull."}, + {"text": "If at first you don't succeed, you're not Chuck Norris."}, + {"text": "If Chuck Norris were a calendar, every month would be named Chucktober, and every day he'd kick your ass."}, + {"text": "# Chuck Norris's show is called Walker: Texas Ranger, because Chuck Norris doesn't run."}, + {"text": "Behind every successful man, there is a woman. Behind every dead man, there is Chuck Norris."}, + {"text": "Chuck Norris brushes his teeth with a mixture of iron shavings, industrial paint remover, and wood-grain alcohol."}, + {"text": "The easiest way to determine Chuck Norris' age is to cut him in half and count the rings."}, + {"text": "There is endless debate about the existence of the human soul. Well it does exist and Chuck Norris finds it delicious."}, + {"text": "Most boots are made for walkin'. Chuck Norris' boots ain't that merciful."}, + {"text": "Chuck Norris wears a live rattlesnake as a condom."}, + {"text": "Chuck Norris began selling the Total Gym as an ill-fated attempt to make his day-to-day opponents less laughably pathetic."}, + {"text": "Do you know why Baskin Robbins only has 31 flavors? Because Chuck Norris doesn't like Fudge Ripple."}, + {"text": "Chuck Norris was what Willis was talkin' about."}, + {"text": "Google won't search for Chuck Norris because it knows you don't find Chuck Norris, he finds you."}, + {"text": "Chuck Norris can lead a horse to water AND make it drink."}, + {"text": "Nagasaki never had a bomb dropped on it. Chuck Norris jumped out of a plane and punched the ground"}, + {"text": "Chuck Norris destroyed the periodic table, because Chuck Norris only recognizes the element of surprise."}, + {"text": "It is believed dinosaurs are extinct due to a giant meteor. That's true if you want to call Chuck Norris a giant meteor."}, + {"text": "Chuck Norris shot the sheriff, but he round house kicked the deputy."}, + {"text": "That's not Chuck Norris doing push-ups -- that's Chuck Norris moving the Earth away from the path of a deadly asteroid."}, + {"text": "Chuck Norris can judge a book by its cover."}, + {"text": "How many Chuck Norris' does it take to change a light bulb? None, Chuck Norris prefers to kill in the dark."}, + {"text": "Crime does not pay - unless you are an undertaker following Walker, Texas Ranger, on a routine patrol."}, + {"text": "Chuck Norris invented the internet? just so he had a place to store his porn."}, + {"text": "Chuck Norris does not own a house. He walks into random houses and people move."}, + {"text": "It is better to give than to receive. This is especially true of a Chuck Norris roundhouse kick."}, + {"text": "Chuck Norris is the only person to ever win a staring contest against Ray Charles and Stevie Wonder."}, + {"text": "Industrial logging isn't the cause of deforestation. Chuck Norris needs toothpicks."}, + {"text": "Chuck Norris smells what the Rock is cooking... because the Rock is Chuck Norris' personal chef."}, + {"text": "Chuck Norris is the reason why Waldo is hiding."}, + {"text": "Chuck Norris does not eat. Food understands that the only safe haven from Chuck Norris' fists is inside his own body."}, + {"text": "One day Chuck Norris walked down the street with a massive erection. There were no survivors."}, + {"text": "Chuck Norris uses a night light. Not because Chuck Norris is afraid of the dark, but the dark is afraid of Chuck Norris."}, + {"text": "When Bruce Banner gets mad, he turns into the Hulk. When the Hulk gets mad, he turns into Chuck Norris."}, + {"text": "Chuck Norris once kicked a horse in the chin. Its descendants are known today as Giraffes."}, + {"text": "Sticks and stones may break your bones, but a Chuck Norris glare will liquefy your kidneys."}, + {"text": "Chuck Norris once went skydiving, but promised never to do it again. One Grand Canyon is enough."}, + {"text": "In a fight between Batman and Darth Vader, the winner would be Chuck Norris."}, + {"text": "Everybody loves Raymond. Except Chuck Norris."}, + {"text": "Chuck Norris got his drivers license at the age of 16. Seconds."}, + {"text": "Chuck Norris can win at solitaire with only 18 cards."}, + {"text": "Chuck Norris once shat blood - the blood of 11,940 natives he had killed and eaten."}, + {"text": "The truth will set you free. Unless Chuck Norris has you, in which case, forget it buddy!"}, + {"text": "Chuck Norris doesn't look both ways before he crosses the street... he just roundhouses any cars that get too close."}, + {"text": "How many roundhouse kicks does it take to get to the center of a tootsie pop? Just one. From Chuck Norris."}, + {"text": "Chuck Norris doesnt wear a watch, HE decides what time it is."}, + {"text": "When Chuck Norris does division, there are no remainders."}, + {"text": "Never look a gift Chuck Norris in the mouth, because he will bite your damn eyes off."}, + {"text": "Chuck Norris? roundhouse kick is so powerful, it can be seen from outer space by the naked eye."}, + {"text": "Ozzy Osbourne bites the heads off of bats. Chuck Norris bites the heads off of Siberian Tigers."}, + {"text": "He who lives by the sword, dies by the sword. He who lives by Chuck Norris, dies by the roundhouse kick."}, + {"text": "The best-laid plans of mice and men often go awry. Even the worst-laid plans of Chuck Norris come off without a hitch."}, + {"text": "Chuck Norris can taste lies."}, + {"text": "One time, Chuck Norris accidentally stubbed his toe. It destroyed the entire state of Ohio."}, + {"text": "Little Miss Muffet sat on her tuffet, until Chuck Norris roundhouse kicked her into a glacier."}, + {"text": "Chuck Norris can blow bubbles with beef jerky."}, + {"text": "Chuck Norris does, in fact, live in a round house."}, + {"text": "When Chuck Norris works out on the Total Gym, the Total Gym feels like it's been raped."}, + {"text": "Chuck Norris can skeletize a cow in two minutes."}, + {"text": "The only sure things are Death and Taxes and when Chuck Norris goes to work for the IRS, they'll be the same thing."}, + {"text": "Chuck Norris' first job was as a paperboy. There were no survivors."}, + {"text": "With the rising cost of gasoline, Chuck Norris is beginning to worry about his drinking habit."}, + {"text": "The square root of Chuck Norris is pain. Do not try to square Chuck Norris, the result is death."}, + {"text": "To be or not to be? That is the question. The answer? Chuck Norris."}, + {"text": "Chuck Norris has never been in a fight, ever. Do you call one roundhouse kick to the face a fight?"}, + {"text": "There are two types of people in the world... people that suck, and Chuck Norris."}, + {"text": "Chuck Norris never wet his bed as a child. The bed wet itself out of fear."}, + {"text": "Chuck Norris uses 8'x10' sheets of plywood as toilet paper."}, + {"text": "Noah was the only man notified before Chuck Norris relieved himself in the Atlantic Ocean."}, + {"text": "Chuck Norris eats steak for every single meal. Most times he forgets to kill the cow."}, + {"text": "The First Law of Thermodynamics states that energy can neither be created nor destroyed... unless it meets Chuck Norris."}, + {"text": "Fact: Chuck Norris doesn't consider it sex if the woman survives."}, + {"text": "Chuck Norris knows everything there is to know - Except for the definition of mercy."}, + {"text": "Chuck Norris never has to wax his skis because they're always slick with blood."}, + {"text": "182,000 Americans die from Chuck Norris-related accidents every year."}, + {"text": "Paper beats rock, rock beats scissors, and scissors beats paper, but Chuck Norris beats all 3 at the same time."}, + {"text": "Jesus can walk on water, but Chuck Norris can walk on Jesus."}, + {"text": "All roads lead to Chuck Norris. And by the transitive property, a roundhouse kick to the face."}, + {"text": "July 4th is Independence day. And the day Chuck Norris was born. Coincidence? I think not."}, + {"text": "Chuck Norris was once in a knife fight, and the knife lost."}, + {"text": "If you work in an office with Chuck Norris, don't ask him for his three-hole-punch."}, + {"text": "The First rule of Chuck Norris is: you do not talk about Chuck Norris."}, + {"text": "When Chuck Norris plays Monopoly, it affects the actual world economy."}, + {"text": "Chuck Norris drinks napalm to quell his heartburn."}, + {"text": "As an infant, Chuck Norris' parents gave him a toy hammer. He gave the world Stonehenge."}, + {"text": "Chuck Norris once ordered a steak in a restaurant. The steak did what it was told."}, + {"text": "There are only two things that can cut diamonds: other diamonds, and Chuck Norris."}, + {"text": "President Roosevelt once rode his horse 100 miles. Chuck Norris carried his the same distance in half the time."}, + {"text": "Chuck Norris qualified with a top speed of 324 mph at the Daytona 500, without a car."}, + {"text": "Chuck Norris likes his coffee half and half: half coffee grounds, half wood-grain alcohol."}, + {"text": "Chuck Norris uses tabasco sauce instead of visine."}, + {"text": "Chuck Norris' credit cards have no limit. Last weekend, he maxed them out."}, + {"text": "Think of a hot woman. Chuck Norris did her."}, + {"text": "Chuck Norris sleeps with a pillow under his gun."}, + {"text": "Chuck Norris doesn't chew gum. Chuck Norris chews tin foil."}, + {"text": "Aliens DO indeed exist. They just know better than to visit a planet that Chuck Norris is on."}, + {"text": "Some people ask for a Kleenex when they sneeze, Chuck Norris asks for a body bag."}, + {"text": "There?s an order to the universe: space, time, Chuck Norris.... Just kidding, Chuck Norris is first."}, + {"text": "Chuck Norris doesn't see dead people. He makes people dead."}, + {"text": "For undercover police work, Chuck Norris pins his badge underneath his shirt, directly into his chest."}, + {"text": "We live in an expanding universe. All of it is trying to get away from Chuck Norris."}, + {"text": "The word 'Kill' was invented by Chuck Norris. Other words were 'Die', 'Beer', and 'What'."}, + {"text": "Chuck Norris is his own line at the DMV."}, + {"text": "Two wrongs don't make a right. Unless you're Chuck Norris. Then two wrongs make a roundhouse kick to the face."}, + {"text": "Who let the dogs out? Chuck Norris let the dogs out... and then roundhouse kicked them through an Oldsmobile."}, + {"text": "When Chuck Norris goes to out to eat, he orders a whole chicken, but he only eats its soul."}, + {"text": "Chuck Norris has never won an Academy Award for acting... because he's not acting."}, + {"text": "If Chuck Norris wants your opinion, he'll beat it into you."}, + {"text": "Not everyone that Chuck Norris is mad at gets killed. Some get away. They are called astronauts."}, + {"text": "Godzilla is a Japanese rendition of Chuck Norris' first visit to Tokyo."}, + {"text": "They once made a Chuck Norris toilet paper, but there was a problem-- It wouldn't take shit from anybody."}, + {"text": "Chuck Norris once rode a nine foot grizzly bear through an automatic car wash, instead of taking a shower."}, + {"text": "Chuck Norris' sperm can be seen with the naked eye. Each one is the size of a quarter."}, + {"text": "Chuck Norris doesn't daydream. He's too busy giving other people nightmares."}, + {"text": "There are no such things as tornados. Chuck Norris just hates trailer parks."}, + {"text": "Chuck Norris' penis is a third degree blackbelt, and an honorable 32nd-degree mason."}, + {"text": "Chuck Norris once participated in the running of the bulls. He walked."}, + {"text": "The Drummer for Def Leppard's only got one arm. Chuck Norris needed a back scratcher."}, + {"text": "Chuck Norris once rode a bull, and nine months later it had a calf."}, + {"text": "For Spring Break '05, Chuck Norris drove to Madagascar, riding a chariot pulled by two electric eels."}, + {"text": "Chuck Norris has banned rainbows from the state of North Dakota."}, + {"text": "Divide Chuck Norris by zero and you will in fact get one........one bad-ass that is."}, + {"text": "TNT was originally developed by Chuck Norris to cure indigestion."}, + {"text": "Chuck Norris runs on batteries. Specifically, Die Hards."}, + {"text": "Chuck Norris will never have a heart attack. His heart isn't nearly foolish enough to attack him."}, + {"text": "Only Chuck Norris can prevent forest fires."}, + {"text": "When Chuck Norris makes a burrito, its main ingredient is real toes."}, + {"text": "Chuck Norris is not Irish. His hair is soaked in the blood of his victims."}, + {"text": "They say curiosity killed the cat. This is false. Chuck Norris killed the cat. Every single one of them."}, + {"text": "There is no such thing as a lesbian, just a woman who has never met Chuck Norris."}, + {"text": "Chuck Norris crossed the road. No one has ever dared question his motives."}, + {"text": "One time, at band camp, Chuck Norris ate a percussionist."}, + {"text": "Love does not hurt. Chuck Norris does."}, + {"text": "Chuck Norris once round-house kicked a salesman. Over the phone."}, + {"text": "The pen is mightier than the sword, but only if the pen is held by Chuck Norris."}, + {"text": "Chuck Norris knows the last digit of pi."}, + {"text": "The air around Chuck Norris is always a balmy 78 degrees."}, + {"text": "When Chuck Norris wants an egg, he cracks open a chicken."}, + {"text": "Chuck Norris plays racquetball with a waffle iron and a bowling ball."}, + {"text": "Chuck Norris doesn't believe in ravioli. He stuffs a live turtle with beef and smothers it in pig's blood."}, + {"text": "Count from one to ten. That's how long it would take Chuck Norris to kill you...Forty seven times."}, + {"text": "Chuck Norris is not Politically Correct. He is just Correct. Always."}, + {"text": "Mr. T pities the fool. Chuck Norris rips the fool's head off."}, + {"text": "Chuck Norris had to stop washing his clothes in the ocean. The tsunamis were killing people."}, + {"text": "Chuck Norris has volunteered to remain on earth after the Rapture; he will spend his time fighting the Anti-Christ."}, + {"text": "Chuck Norris is the only known mammal in history to have an opposable thumb. On his penis."}, + {"text": "Chuck Norris' favorite cereal is Kellogg's Nails 'N' Gravel."}, + {"text": "Chuck Norris does not wear a condom. Because there is no such thing as protection from Chuck Norris."}, + {"text": "Rules of fighting: 1) Don't bring a knife to a gun fight. 2) Don't bring a gun to a Chuck Norris fight."}, + {"text": "Chuck Norris is the only man who has, literally, beaten the odds. With his fists."}, + {"text": "Chuck Norris wipes his ass with chain mail and sandpaper."}, + {"text": "Chuck Norris likes his ice like he likes his skulls: crushed."}, + {"text": "Chuck Norris can kick through all 6 degrees of separation, hitting anyone, anywhere, in the face, at any time."}, + {"text": "Most tough men eat nails for breakfast. Chuck Norris does all of his grocery shopping at Home Depot."}, + {"text": "Everything King Midas touches turnes to gold. Everything Chuck Norris touches turns up dead."}, + {"text": "When Chuck Norris throws exceptions, it's across the room."}, + {"text": "All arrays Chuck Norris declares are of infinite size, because Chuck Norris knows no bounds."}, + {"text": "Chuck Norris doesn't have disk latency because the hard drive knows to hurry the hell up."}, + {"text": "Chuck Norris writes code that optimizes itself."}, + {"text": "Chuck Norris can't test for equality because he has no equal."}, + {"text": "Chuck Norris doesn't need garbage collection because he doesn't call .Dispose(), he calls .DropKick()."}, + {"text": "Chuck Norris's first program was kill -9."}, + {"text": "Chuck Norris burst the dot com bubble."}, + {"text": "All browsers support the hex definitions #chuck and #norris for the colors black and blue."}, + {"text": "MySpace actually isn't your space, it's Chuck's (he just lets you use it)."}, + {"text": "Chuck Norris can write infinite recursion functions and have them return."}, + {"text": "Chuck Norris can solve the Towers of Hanoi in one move."}, + {"text": "The only pattern Chuck Norris knows is God Object."}, + {"text": "Chuck Norris finished World of Warcraft."}, + {"text": "Project managers never ask Chuck Norris for estimations... ever."}, + {"text": "Chuck Norris doesn't use web standards as the web will conform to him."}, + {"text": "Whiteboards are white because Chuck Norris scared them that way."}, + {"text": "Chuck Norris can delete the Recycling Bin."}, + {"text": "Chuck Norris can unit test entire applications with a single assert."}, + {"text": "Chuck Norris doesn't bug hunt as that signifies a probability of failure, he goes bug killing."}, + {"text": "Chuck Norris's keyboard doesn't have a Ctrl key because nothing controls Chuck Norris."}, + {"text": "Chuck Norris doesn't need a debugger, he just stares down the bug until the code confesses."}, + {"text": "Chuck Norris can access private methods."}, + {"text": "The class object inherits from Chuck Norris"}, + {"text": "Bill Gates thinks he's Chuck Norris. Chuck Norris actually laughed. Once."}, + {"text": "No statement can catch the ChuckNorrisException."}, + {"text": "Chuck Norris can write multi-threaded applications with a single thread."}, + {"text": "Chuck Norris doesn't need to use AJAX because pages are too afraid to postback anyways."}, + {"text": "Chuck Norris doesn't use reflection, reflection asks politely for his help."}, + {"text": "There is no Esc key on Chuck Norris' keyboard, because no one escapes Chuck Norris."}, + {"text": "Chuck Norris can binary search unsorted data."}, + {"text": "Chuck Norris doesn't needs try-catch, exceptions are too afraid to raise."}, + {"text": "Chuck Norris went out of an infinite loop."}, + {"text": "If Chuck Norris writes code with bugs, the bugs fix themselves."}, + {"text": "Chuck Norris hosting is 101% uptime guaranteed."}, + {"text": "Chuck Norris's keyboard has the Any key."}, + {"text": "Chuck Norris can access the DB from the UI."}, + {"text": "Chuck Norris' programs never exit, they terminate."}, + {"text": "Chuck Norris protocol design method has no status, requests or responses, only commands."}, + {"text": "Chuck Norris programs occupy 150% of CPU, even when they are not executing."}, + {"text": "Chuck Norris programs do not accept input."}, + {"text": "Chuck Norris doesn't need an OS."}, + {"text": "Chuck Norris can compile syntax errors."}, + {"text": "Every SQL statement that Chuck Norris codes has an implicit COMMIT in its end."}, + {"text": "Chuck Norris does not code in cycles, he codes in strikes."}, + {"text": "Chuck Norris doesn't use a computer because a computer does everything slower than Chuck Norris."}, + {"text": "Chuck Norris compresses his files by doing a flying round house kick to the hard drive."}, + {"text": "Chuck Norris doesn't cheat death. He wins fair and square."}, + {"text": "Chuck Norris once won a game of connect four in 3 moves."}, + {"text": "Chuck Norris can do a wheelie on a unicycle."}, + {"text": "Chuck Norris can win in a game of Russian roulette with a fully loaded gun."}, + {"text": "No one has ever pair-programmed with Chuck Norris and lived to tell about it."}, + {"text": "No one has ever spoken during review of Chuck Norris' code and lived to tell about it."}, + {"text": "Chuck Norris doesn't use Oracle, he is the Oracle."}, + {"text": "Jesus can walk on water, but Chuck Norris can swim through land."}, + {"text": "A diff between your code and Chuck Norris's is infinite."}, + {"text": "The Chuck Norris Eclipse plugin made alien contact."}, + {"text": "Chuck Norris is the ultimate mutex, all threads fear him."}, + {"text": "Don't worry about tests, Chuck Norris's test cases cover your code too."}, + {"text": "When Chuck Norris break the build, you can't fix it, because there is not a single line of code left."}, + {"text": "Chuck Norris types with one finger. He points it at the keyboard and the keyboard does the rest."}, + {"text": "Chuck Norris's brain waves are suspected to be harmful to cell phones."}, + {"text": "Chuck Norris does infinite loops in 4 seconds."}, + {"text": "Product Owners never ask Chuck Norris for more features. They ask for mercy."}, + {"text": "Chuck Norris killed two stones with one bird."}, + {"text": "Chuck Norris can speak Braille."}, + {"text": "Chuck Norris knows the value of NULL, and he can sort by it too."}, + {"text": "Chuck Norris can install a 64 bit OS on 32 bit machines."}, + {"text": "Chuck Norris can write to an output stream."}, + {"text": "Chuck Norris can read from an input stream."}, + {"text": "Chuck Norris never has to build his program to machine code. Machines have learnt to interpret Chuck Norris code."}, + {"text": "Chuck Norris' unit tests don't run. They die."}, + {"text": "Chuck Norris sits at the stand-up."}, + {"text": "Chuck Norris doesn't need an account. He just logs in."}, + {"text": "Code runs faster when Chuck Norris watches it."}, + {"text": "Chuck Norris does not need a watch, he decides what time it is."}, + {"text": "Chuck Norris already went to Moon and Mars, that's why there are no signs of life."}, + {"text": "Once a police officer caught Chuck Norris, the cop was lucky enough to escape with a warning."}, + {"text": "Chuck Norris knows Victoria's secret."}, + {"text": "Dark spots on the Moon are the result of Chuck Norris' shooting practice."}, + {"text": "Chuck Norris died before 20 years, Death doesn't have the courage to tell him yet."}, + {"text": "There is no April 1st in Chuck Norris' calendar, because no one can fool him."}, + {"text": "Chuck Norris can make onions cry."}, + {"text": "Chuck Norris can watch the radio."}, + {"text": "Chuck Norris built the hospital he was born in."}, + {"text": "Once Chuck Norris signed a cheque and the bank bounced."}, + {"text": "Chuck Norris can drown a fish."}, + {"text": "Once death had a near Chuck Norris experience."}, + {"text": "Once Chuck Norris and Superman had a competition. The loser had to wear his underwear over his pants."}, + {"text": "Chuck Norris can make fire using two ice cubes."}, + {"text": "Chuck Norris tears can cure the cancer, but the sad thing is Chuck Norris never cries."}, + {"text": "Chuck Norris can remember the future."}, + {"text": "Chuck Norris doesn't age, because time cannot keep up with him."}, + {"text": "Ghosts are actually caused by Chuck Norris killing people faster than Death can process them."}, + {"text": "Chuck Norris doesn't need a keyboard he tells the computer to write something and it does."}, + {"text": "Chuck Norris plays pool with comets and astroids. He shoots them into black holes."}, + {"text": "There was never anything wrong with Achilles' heel until he got mad and decided to kick Chuck Norris."}, + {"text": "Tornados occur when Chuck Norris sneezes."}, + {"text": "Chuck Norris once sold eBay to eBay on eBay."}, + {"text": "Chuck Norris can build a snowman out of rain."}, + {"text": "Chuck Norris made the sun by rubbing his hands together."}, + {"text": "Chuck Norris puts sunglasses on to protect the sun from his eyes."}, + {"text": "Chuck Norris can lock a safe and keep the key inside it."} +] diff --git a/app/routers/about_us.py b/app/routers/about_us.py index e7aa7f98..e9d03681 100644 --- a/app/routers/about_us.py +++ b/app/routers/about_us.py @@ -2,12 +2,14 @@ from app.dependencies import templates - router = APIRouter() @router.get("/about") def about(request: Request): - return templates.TemplateResponse("about_us.html", { - "request": request, - }) + return templates.TemplateResponse( + "about_us.html", + { + "request": request, + }, + ) diff --git a/app/routers/agenda.py b/app/routers/agenda.py index c25012ce..51d304ce 100644 --- a/app/routers/agenda.py +++ b/app/routers/agenda.py @@ -1,8 +1,10 @@ +import json from collections import defaultdict from datetime import date, timedelta from typing import Optional, Tuple from fastapi import APIRouter, Depends, Request +from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session from starlette.templating import _TemplateResponse @@ -13,9 +15,9 @@ def calc_dates_range_for_agenda( - start: Optional[date], - end: Optional[date], - days: Optional[int], + start: Optional[date], + end: Optional[date], + days: Optional[int], ) -> Tuple[date, date]: """Create start and end dates according to the parameters in the page.""" if days is not None: @@ -29,32 +31,51 @@ def calc_dates_range_for_agenda( @router.get("/agenda", include_in_schema=False) def agenda( - request: Request, - db: Session = Depends(get_db), - start_date: Optional[date] = None, - end_date: Optional[date] = None, - days: Optional[int] = None, + request: Request, + db: Session = Depends(get_db), + start_date: Optional[date] = None, + end_date: Optional[date] = None, + days: Optional[int] = None, ) -> _TemplateResponse: """Route for the agenda page, using dates range or exact amount of days.""" user_id = 1 # there is no user session yet, so I use user id- 1. + start_date, end_date = calc_dates_range_for_agenda( - start_date, end_date, days + start_date, + end_date, + days, ) events_objects = agenda_events.get_events_per_dates( - db, user_id, start_date, end_date + db, + user_id, + start_date, + end_date, ) events = defaultdict(list) + for event_obj in events_objects: event_duration = agenda_events.get_time_delta_string( - event_obj.start, event_obj.end + event_obj.start, + event_obj.end, ) - events[event_obj.start.date()].append((event_obj, event_duration)) - - return templates.TemplateResponse("agenda.html", { - "request": request, - "events": events, - "start_date": start_date, - "end_date": end_date, - }) + json_event_data = jsonable_encoder(event_obj) + json_event_data["duration"] = event_duration + json_event_data["start"] = event_obj.start.time().strftime("%H:%M") + event_key = event_obj.start.date().strftime("%d/%m/%Y") + events[event_key].append(json_event_data) + + events_for_graph = json.dumps( + agenda_events.make_dict_for_graph_data(db, user_id), + ) + return templates.TemplateResponse( + "agenda.html", + { + "request": request, + "events": events, + "start_date": start_date, + "end_date": end_date, + "events_for_graph": events_for_graph, + }, + ) diff --git a/app/routers/audio.py b/app/routers/audio.py new file mode 100644 index 00000000..c53ed9ca --- /dev/null +++ b/app/routers/audio.py @@ -0,0 +1,149 @@ +import json +from pathlib import Path +from typing import List, Optional + +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm.session import Session +from starlette.responses import RedirectResponse +from starlette.status import HTTP_302_FOUND + +from app.database.models import User +from app.dependencies import SOUNDS_PATH, get_db, templates +from app.internal.audio import ( + DEFAULT_MUSIC, + DEFAULT_MUSIC_VOL, + DEFAULT_SFX, + DEFAULT_SFX_VOL, + Sound, + SoundKind, + get_audio_settings, + handle_vol, + init_audio_tracks, + save_audio_settings, +) +from app.internal.security.dependencies import current_user + +router = APIRouter( + prefix="/audio", + tags=["audio"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/settings") +def audio_settings( + request: Request, + session: Session = Depends(get_db), + user: User = Depends(current_user), +) -> templates.TemplateResponse: + """A route to the audio settings. + Args: + request (Request): the http request + session (Session): the database. + Returns: + templates.TemplateResponse: renders the audio.html page + with the relevant information. + """ + mp3_files = Path(SOUNDS_PATH).glob("**/*.mp3") + wav_files = Path(SOUNDS_PATH).glob("**/*.wav") + songs = [Sound(SoundKind.SONG, path.stem, path) for path in mp3_files] + sfxs = [Sound(SoundKind.SFX, path.stem, path) for path in wav_files] + sounds = songs + sfxs + init_audio_tracks(session, sounds) + + return templates.TemplateResponse( + "audio_settings.html", + { + "request": request, + "songs": songs, + "sound_effects": sfxs, + }, + ) + + +@router.post("/settings") +async def get_choices( + session: Session = Depends(get_db), + music_on: bool = Form(...), + music_choices: Optional[List[str]] = Form(None), + music_vol: Optional[int] = Form(None), + sfx_on: bool = Form(...), + sfx_choice: Optional[str] = Form(None), + sfx_vol: Optional[int] = Form(None), + user: User = Depends(current_user), +) -> RedirectResponse: + """This function saves users' choices in the db. + Args: + request (Request): the http request + session (Session): the database. + music_on_off (str, optional): On if the user chose to enable music, + false otherwise. + music_choices (Optional[List[str]], optional): a list of music tracks + if music is enabled, None otherwise. + music_vol (Optional[int], optional): a number in the range (0, 1) + indicating the desired music volume, or None if disabled. + sfx_on_off (str, optional): On if the user chose to enable + sound effects, false otherwise. + sfx_choice (Optional[str], optional): chosen sound effect for + mouse click if sound effects are enabled, None otherwise. + sfx_vol (Optional[int], optional): a number in the range (0, 1) + indicating the desired sfx volume, or None if disabled. + user (User): current user. + Returns: + RedirectResponse: redirect the user to home.html. + """ + user_choices = { + "music_on": music_on, + "music_vol": music_vol, + "sfx_on": sfx_on, + "sfx_vol": sfx_vol, + } + save_audio_settings(session, music_choices, sfx_choice, user_choices, user) + + return RedirectResponse("/", status_code=HTTP_302_FOUND) + + +@router.get("/start") +async def start_audio( + session: Session = Depends(get_db), + user: User = Depends(current_user), +) -> RedirectResponse: + """Starts audio according to audio settings. + Args: + session (Session): the database. + Returns: + RedirectResponse: redirect the user to home.html. + """ + ( + music_on, + playlist, + music_vol, + sfx_on, + sfx_choice, + sfx_vol, + ) = get_audio_settings(session, user.user_id) + if music_on is not None: + music_vol = handle_vol(music_on, music_vol) + sfx_vol = handle_vol(sfx_on, sfx_vol) + + if not playlist: + playlist = DEFAULT_MUSIC + music_vol = DEFAULT_MUSIC_VOL + + if not sfx_choice: + chosen_sfx = DEFAULT_SFX + chosen_sfx_vol = DEFAULT_SFX_VOL + else: + chosen_sfx = sfx_choice + chosen_sfx_vol = sfx_vol + + return json.dumps( + { + "music_on": music_on, + "playlist": playlist, + "music_vol": music_vol, + "sfx_on": sfx_on, + "sfx_choice": chosen_sfx, + "sfx_vol": chosen_sfx_vol, + }, + ) diff --git a/app/routers/calendar_grid.py b/app/routers/calendar_grid.py index b8b0878f..9ef5202c 100644 --- a/app/routers/calendar_grid.py +++ b/app/routers/calendar_grid.py @@ -1,7 +1,7 @@ import calendar -from datetime import date, datetime, timedelta import itertools import locale +from datetime import date, datetime, timedelta from typing import Dict, Iterator, List, Tuple import pytz @@ -32,21 +32,16 @@ def __init__(self, date: datetime): self.dailyevents: List[Tuple] = [] self.events: List[Tuple] = [] self.css: Dict[str, str] = { - 'day_container': 'day', - 'date': 'day-number', - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily', - 'front', - 'background-warmyellow' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": "day", + "date": "day-number", + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily", "front", "background-warmyellow"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } def __str__(self) -> str: @@ -56,18 +51,31 @@ def display(self) -> str: """Returns day date inf the format of 00 MONTH 00""" return self.date.strftime("%d %B %y").upper() + def dayview_format(self) -> str: + """Returns day date in the format of yyyy-mm-dd""" + return self.date.strftime("%Y-%m-%d") + + def weekview_format(self) -> str: + """Returns the first day of week in the format of yyyy-mm-dd""" + day = self.date + if day.strftime("%A") == "Sunday": + return self.dayview_format() + while day.strftime("%A") != "Sunday": + day -= timedelta(days=1) + return day.strftime("%Y-%m-%d") + def set_id(self) -> str: """Returns day date inf the format of 00-month-0000""" return self.date.strftime("%d-%B-%Y") @classmethod def get_user_local_time(cls) -> datetime: - greenwich = pytz.timezone('GB') + greenwich = pytz.timezone("GB") return greenwich.localize(datetime.now()) @classmethod def convert_str_to_date(cls, date_string: str) -> datetime: - return datetime.strptime(date_string, '%d-%B-%Y') + return datetime.strptime(date_string, "%d-%B-%Y") @classmethod def is_weekend(cls, date: date) -> bool: @@ -79,21 +87,16 @@ class DayWeekend(Day): def __init__(self, date: datetime): super().__init__(date) self.css = { - 'day_container': 'day ', - 'date': ' '.join(['day-number', 'text-gray']), - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily', - 'front', - 'background-warmyellow' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": "day ", + "date": " ".join(["day-number", "text-gray"]), + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily", "front", "background-warmyellow"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } @@ -101,26 +104,18 @@ class Today(Day): def __init__(self, date: datetime): super().__init__(date) self.css = { - 'day_container': ' '.join([ - 'day', - 'text-darkblue', - 'background-yellow' - ]), - 'date': 'day-number', - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily', - 'front', - 'text-lightgray', - 'background-darkblue' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": " ".join( + ["day", "text-darkblue", "background-yellow"], + ), + "date": "day-number", + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily", "front", "text-lightgray", "background-darkblue"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } @@ -128,25 +123,18 @@ class FirstDayMonth(Day): def __init__(self, date: datetime): super().__init__(date) self.css = { - 'day_container': ' '.join([ - 'day', - 'text-darkblue', - 'background-lightgray' - ]), - 'date': 'day-number', - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily front', - 'text-lightgray', - 'background-red' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": " ".join( + ["day", "text-darkblue", "background-lightgray"], + ), + "date": "day-number", + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily front", "text-lightgray", "background-red"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } def __str__(self) -> str: @@ -175,8 +163,7 @@ def create_day(day: datetime) -> Day: def get_next_date(date: datetime) -> Iterator[Day]: """Generate date objects from a starting given date.""" yield from ( - create_day(date + timedelta(days=i)) - for i in itertools.count(start=1) + create_day(date + timedelta(days=i)) for i in itertools.count(start=1) ) @@ -197,13 +184,13 @@ def get_n_days(date: datetime, n: int) -> Iterator[Day]: def create_weeks( - days: Iterator[Day], - length: int = Week.WEEK_DAYS + days: Iterator[Day], + length: int = Week.WEEK_DAYS, ) -> List[Week]: """Return lists of Weeks objects.""" ndays: List[Day] = list(days) num_days: int = len(ndays) - return [Week(ndays[i:i + length]) for i in range(0, num_days, length)] + return [Week(ndays[i : i + length]) for i in range(0, num_days, length)] def get_month_block(day: Day, n: int = MONTH_BLOCK) -> List[Week]: diff --git a/app/routers/categories.py b/app/routers/categories.py index 45522ccd..c48334d3 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -1,15 +1,16 @@ import re -from typing import Any, Dict, List +from typing import Dict, List -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request from pydantic import BaseModel from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from starlette import status from starlette.datastructures import ImmutableMultiDict +from starlette.templating import _TemplateResponse from app.database.models import Category -from app.dependencies import get_db +from app.dependencies import get_db, templates HEX_COLOR_FORMAT = r"^(?:[0-9a-fA-F]{3}){1,2}$" @@ -30,55 +31,62 @@ class Config: "name": "Guitar lessons", "color": "aabbcc", "user_id": 1, - } + }, } # TODO(issue#29): get current user_id from session -@router.get("/", include_in_schema=False) -def get_categories(request: Request, - db_session: Session = Depends(get_db)) -> List[Category]: +@router.get("/user", include_in_schema=False) +def get_categories( + request: Request, + db_session: Session = Depends(get_db), +) -> List[Category]: if validate_request_params(request.query_params): return get_user_categories(db_session, **request.query_params) else: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Request {request.query_params} contains " - f"unallowed params.") - - -@router.get("/list") -def get_all_categories( - db_session: Session = Depends(get_db)) -> List[Category]: - return db_session.query(Category).all() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Request {request.query_params} contains " + f"unallowed params.", + ) @router.get("/") -def get_categories_by_user_id( - user_id: int, db_session: Session = Depends(get_db) -) -> List[Category]: - return get_user_categories(db_session, user_id) +def category_color_insert(request: Request) -> _TemplateResponse: + return templates.TemplateResponse("categories.html", {"request": request}) # TODO(issue#29): get current user_id from session @router.post("/") -async def set_category(category: CategoryModel, - db_sess: Session = Depends(get_db)) -> Dict[str, Any]: - if not validate_color_format(category.color): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Color {category.color} if not from " - f"expected format.") +async def set_category( + request: Request, + name: str = Form(None), + color: str = Form(None), + db_sess: Session = Depends(get_db), +): + + message = "" + user_id = 1 # until issue#29 will get current user_id from session + color = color.replace("#", "") + if not validate_color_format(color): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Color {color} if not from " f"expected format.", + ) try: - cat = Category.create(db_sess, - name=category.name, - color=category.color, - user_id=category.user_id) + Category.create(db_sess, name=name, color=color, user_id=user_id) except IntegrityError: db_sess.rollback() - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"category is already exists for " - f"user {category.user_id}.") - else: - return {"category": cat.to_dict()} + message = "Category already exists" + return templates.TemplateResponse( + "categories.html", + dictionary_req(request, message, name, color), + ) + message = f"Congratulation! You have created a new category: {name}" + return templates.TemplateResponse( + "categories.html", + dictionary_req(request, message, name, color), + ) def validate_request_params(query_params: ImmutableMultiDict) -> bool: @@ -95,8 +103,11 @@ def validate_request_params(query_params: ImmutableMultiDict) -> bool: intersection_set = request_params.intersection(all_fields) if "color" in intersection_set: is_valid_color = validate_color_format(query_params["color"]) - return union_set == all_fields and "user_id" in intersection_set and ( - is_valid_color) + return ( + union_set == all_fields + and "user_id" in intersection_set + and (is_valid_color) + ) def validate_color_format(color: str) -> bool: @@ -108,15 +119,30 @@ def validate_color_format(color: str) -> bool: return False -def get_user_categories(db_session: Session, - user_id: int, **params) -> List[Category]: +def get_user_categories( + db_session: Session, user_id: int, **params +) -> List[Category]: """ Returns user's categories, filtered by params. """ try: - categories = db_session.query(Category).filter_by( - user_id=user_id).filter_by(**params).all() + categories = ( + db_session.query(Category) + .filter_by(user_id=user_id) + .filter_by(**params) + .all() + ) except SQLAlchemyError: return [] else: return categories + + +def dictionary_req(request, message, name, color) -> Dict: + dictionary_tamplates = { + "request": request, + "message": message, + "name": name, + "color": color, + } + return dictionary_tamplates diff --git a/app/routers/credits.py b/app/routers/credits.py index 1a35fd4b..59f8d7f0 100644 --- a/app/routers/credits.py +++ b/app/routers/credits.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Request import json from typing import List +from fastapi import APIRouter, Request from loguru import logger from starlette.templating import _TemplateResponse @@ -14,11 +14,10 @@ def credits_from_json() -> List: path = RESOURCES_DIR / "credits.json" try: - with open(path, 'r') as json_file: + with open(path, "r") as json_file: json_list = json.load(json_file) except (IOError, ValueError): - logger.exception( - "An error occurred during reading of json file") + logger.exception("An error occurred during reading of json file") return [] return json_list @@ -26,7 +25,7 @@ def credits_from_json() -> List: @router.get("/credits") def credits(request: Request) -> _TemplateResponse: credit_list = credits_from_json() - return templates.TemplateResponse("credits.html", { - "request": request, - "credit_list": credit_list - }) + return templates.TemplateResponse( + "credits.html", + {"request": request, "credit_list": credit_list}, + ) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 6b4f887e..f826e107 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -1,13 +1,14 @@ from bisect import bisect_left from datetime import datetime, timedelta -from typing import Iterator, Optional, Tuple, Union +from typing import Dict, Iterator, Optional, Tuple, Union -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request -from app.database.models import Event, User +from app.database.models import Event, Task, User from app.dependencies import get_db, templates -from app.internal import zodiac -from app.routers.user import get_all_user_events +from app.internal import international_days, zodiac +from app.internal.security.dependencies import current_user +from app.internal.user.user import get_all_user_events router = APIRouter() @@ -26,6 +27,50 @@ class DivAttributes: CLASS_SIZES = ("title-size-tiny", "title-size-xsmall", "title-size-small") LENGTH_SIZE_STEP = (30, 45, 90) + def _minutes_position(self, minutes: int) -> Dict[str, int]: + """ + Provides info about the minutes value. + Returns a Dict that contains- + 'minutes position': calculates the number of grid bar quarters + that the minutes value covers (from 1 to 4). + 'min_deviation': calculates the 'spare' minutes left out + of a grid bar quarter. + (used to indicate the accurate current time) + """ + min_minutes = self.MIN_MINUTES + max_minutes = self.MAX_MINUTES + for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): + if min_minutes < minutes <= max_minutes: + minute_deviation = minutes - (i - 1) * self.MAX_MINUTES + return {"min_position": i, "min_deviation": minute_deviation} + min_minutes = max_minutes + max_minutes += self.MAX_MINUTES + + def _get_position(self, time: datetime) -> int: + grid_hour_position = time.hour * self.FULL_GRID_BAR + grid_minutes_modifier = self._minutes_position(time.minute) + if grid_minutes_modifier is None: + grid_minutes_modifier = 0 + else: + grid_minutes_modifier = grid_minutes_modifier["min_position"] + return grid_hour_position + grid_minutes_modifier + self.BASE_GRID_BAR + + +class CurrentTimeAttributes(DivAttributes): + def __init__(self, date: datetime) -> None: + current = datetime.now() + self.dayview_date = date.date() + self.is_viewed = self._date_is_today() + self.grid_position = self._get_position(current) - 1 + self.sub_grid_position = self._minutes_position(current.minute) + self.sub_grid_position = self.sub_grid_position["min_deviation"] + + def _date_is_today(self) -> bool: + today = datetime.today().date() + return today == self.dayview_date + + +class EventsAttributes(DivAttributes): def __init__( self, event: Event, @@ -46,23 +91,6 @@ def _check_color(self, color: str) -> str: return self.DEFAULT_COLOR return color - def _minutes_position(self, minutes: int) -> Union[int, None]: - min_minutes = self.MIN_MINUTES - max_minutes = self.MAX_MINUTES - for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): - if min_minutes < minutes <= max_minutes: - return i - min_minutes = max_minutes - max_minutes += 15 - return None - - def _get_position(self, time: datetime) -> int: - grid_hour_position = time.hour * self.FULL_GRID_BAR - grid_minutes_modifier = self._minutes_position(time.minute) - if grid_minutes_modifier is None: - grid_minutes_modifier = 0 - return grid_hour_position + grid_minutes_modifier + self.BASE_GRID_BAR - def _set_grid_position(self) -> str: if self.start_multiday: start = self.FIRST_GRID_BAR @@ -137,12 +165,12 @@ def get_events_and_attributes( day: datetime, session, user_id: int, -) -> Iterator[Tuple[Event, DivAttributes]]: +) -> Iterator[Tuple[Event, EventsAttributes]]: events = get_all_user_events(session, user_id) day_end = day + timedelta(hours=24) for event in events: if is_specific_time_event_in_day(event, day, day_end): - yield event, DivAttributes(event, day) + yield event, EventsAttributes(event, day) def get_all_day_events( @@ -154,49 +182,54 @@ def get_all_day_events( day_end = day + timedelta(hours=24) for event in events: if is_all_day_event_in_day(event=event, day=day, day_end=day_end): - yield (event) + yield event @router.get("/day/{date}", include_in_schema=False) async def dayview( request: Request, date: str, - session=Depends(get_db), view="day", + session=Depends(get_db), + user: User = Depends(current_user), ): - # TODO: add a login session - user = session.query(User).filter_by(username="test_username").first() - if not user: - error_message = "User not found." - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=error_message, - ) try: day = datetime.strptime(date, "%Y-%m-%d") except ValueError as err: raise HTTPException(status_code=404, detail=f"{err}") zodiac_obj = zodiac.get_zodiac_of_day(session, day) - events_n_attrs = get_events_and_attributes( + events_with_attrs = get_events_and_attributes( day=day, session=session, - user_id=user.id, + user_id=user.user_id, ) all_day_events = get_all_day_events( day=day, session=session, - user_id=user.id, + user_id=user.user_id, + ) + current_time_with_attrs = CurrentTimeAttributes(date=day) + inter_day = international_days.get_international_day_per_day(session, day) + tasks = ( + session.query(Task) + .filter(Task.owner_id == user.user_id) + .filter(Task.date == day.date()) + .order_by(Task.time) ) month = day.strftime("%B").upper() return templates.TemplateResponse( "calendar_day_view.html", { "request": request, - "events": events_n_attrs, + "events_and_attrs": events_with_attrs, "all_day_events": all_day_events, "month": month, "day": day.day, + "date_str": date, + "international_day": inter_day, "zodiac": zodiac_obj, "view": view, + "current_time": current_time_with_attrs, + "tasks": tasks, }, ) diff --git a/app/routers/event.py b/app/routers/event.py index 124ff64e..edc28a2f 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -1,33 +1,54 @@ -from datetime import datetime as dt +import io import json +import urllib +from datetime import datetime as dt from operator import attrgetter -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, NamedTuple, Optional, Tuple -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, File, HTTPException, Request +from PIL import Image from pydantic import BaseModel from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound +from sqlalchemy.sql.elements import Null from starlette import status +from starlette.datastructures import ImmutableMultiDict from starlette.responses import RedirectResponse, Response from starlette.templating import _TemplateResponse -from app.database.models import Comment, Event, User, UserEvent -from app.dependencies import get_db, logger, templates +from app.config import PICTURE_EXTENSION +from app.database.models import ( + Category, + Comment, + Country, + Event, + SharedList, + SharedListItem, + User, + UserEvent, +) +from app.dependencies import UPLOAD_PATH, get_db, logger, templates +from app.internal import comment as cmt +from app.internal.emotion import get_emotion from app.internal.event import ( + get_all_countries_names, get_invited_emails, + get_location_coordinates, get_messages, get_uninvited_regular_emails, raise_if_zoom_link_invalid, ) -from app.internal import comment as cmt -from app.internal.emotion import get_emotion +from app.internal.privacy import PrivacyKinds +from app.internal.security.dependencies import current_user from app.internal.utils import create_model, get_current_user +from app.routers.categories import get_user_categories - +IMAGE_HEIGHT = 200 EVENT_DATA = Tuple[Event, List[Dict[str, str]], str] TIME_FORMAT = "%Y-%m-%d %H:%M" START_FORMAT = "%A, %d/%m/%Y %H:%M" + UPDATE_EVENTS_FIELDS = { "title": str, "start": dt, @@ -41,6 +62,7 @@ "category_id": (int, type(None)), } + router = APIRouter( prefix="/event", tags=["event"], @@ -48,6 +70,12 @@ ) +class SharedItem(NamedTuple): + name: str + amount: float + participant: str + + class EventModel(BaseModel): title: str start: dt @@ -69,7 +97,7 @@ async def create_event_api(event: EventModel, session=Depends(get_db)): db=session, title=event.title, start=event.start, - end=event.start, + end=event.end, content=event.content, owner_id=event.owner_id, location=event.location, @@ -78,15 +106,36 @@ async def create_event_api(event: EventModel, session=Depends(get_db)): return {"success": True} +def get_categories_list( + user: User, + db_session: Session = Depends(get_db), +) -> List[Category]: + return get_user_categories(db_session, user.user_id) + + @router.get("/edit", include_in_schema=False) -@router.get("/edit") -async def eventedit(request: Request) -> Response: - return templates.TemplateResponse("eventedit.html", {"request": request}) +async def eventedit( + request: Request, + db_session: Session = Depends(get_db), + user: User = Depends(current_user), +) -> Response: + countries_names = get_all_countries_names(db_session) + categories_list = get_categories_list(user, db_session) + return templates.TemplateResponse( + "eventedit.html", + { + "request": request, + "categories_list": categories_list, + "privacy": PrivacyKinds, + "countries_names": countries_names, + }, + ) @router.post("/edit", include_in_schema=False) async def create_new_event( request: Request, + event_img: bytes = File(None), session=Depends(get_db), ) -> Response: data = await request.form() @@ -101,11 +150,13 @@ async def create_new_event( availability = data.get("availability", "True") == "True" location = data["location"] all_day = data["event_type"] and data["event_type"] == "on" - - vc_link = data["vc_link"] + vc_link = data.get("vc_link") category_id = data.get("category_id") + privacy = data["privacy"] + privacy_kinds = [kind.name for kind in PrivacyKinds] + if privacy not in privacy_kinds: + privacy = PrivacyKinds.Public.name is_google_event = data.get("is_google_event", "True") == "True" - invited_emails = get_invited_emails(data["invited"]) uninvited_contacts = get_uninvited_regular_emails( session, @@ -113,34 +164,102 @@ async def create_new_event( title, invited_emails, ) + shared_list = extract_shared_list_from_data(event_info=data, db=session) + latitude, longitude = None, None - if vc_link is not None: + if vc_link: raise_if_zoom_link_invalid(vc_link) + else: + location_details = await get_location_coordinates(location) + if not isinstance(location_details, str): + location = location_details.name + latitude = location_details.latitude + longitude = location_details.longitude event = create_event( db=session, title=title, start=start, end=end, - owner_id=owner_id, all_day=all_day, + owner_id=owner_id, content=content, location=location, + latitude=latitude, + longitude=longitude, vc_link=vc_link, invitees=invited_emails, category_id=category_id, availability=availability, is_google_event=is_google_event, + shared_list=shared_list, + privacy=privacy, ) + if event_img: + image = process_image(event_img, event.id) + event.image = image + session.commit() + messages = get_messages(session, event, uninvited_contacts) return RedirectResponse( router.url_path_for("eventview", event_id=event.id) - + f'messages={"---".join(messages)}', + + f'?messages={"---".join(messages)}', status_code=status.HTTP_302_FOUND, ) +def process_image( + img: bytes, + event_id: int, + img_height: int = IMAGE_HEIGHT, +) -> str: + """Resized and saves picture without exif (to avoid malicious date)) + according to required height and keep aspect ratio""" + try: + image = Image.open(io.BytesIO(img)) + except IOError: + error_message = "The uploaded file is not a valid image" + logger.exception(error_message) + return + width, height = image.size + height_to_req_height = img_height / float(height) + new_width = int(float(width) * float(height_to_req_height)) + resized = image.resize((new_width, img_height), Image.ANTIALIAS) + file_name = f"{event_id}{PICTURE_EXTENSION}" + image_data = list(resized.getdata()) + image_without_exif = Image.new(resized.mode, resized.size) + image_without_exif.putdata(image_data) + image_without_exif.save(f"{UPLOAD_PATH}/{file_name}") + return file_name + + +def get_waze_link(event: Event) -> str: + """Get a waze navigation link to the event location. + + Returns: + If there are coordinates, waze will navigate to the exact location. + Otherwise, waze will look for the address that appears in the location. + If there is no address, an empty string will be returned.""" + + if not event.location: + return "" + # if event.latitude and event.longitude: + # coordinates = f"{event.latitude},{event.longitude}" + # return f"https://waze.com/ul?ll={coordinates}&navigate=yes" + url_location = urllib.parse.quote(event.location) + return f"https://waze.com/ul?q={url_location}&navigate=yes" + + +def raise_for_nonexisting_event(event_id: int) -> None: + error_message = f"Event ID does not exist. ID: {event_id}" + logger.exception(error_message) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=error_message, + ) + + @router.get("/{event_id}", include_in_schema=False) async def eventview( request: Request, @@ -152,12 +271,17 @@ async def eventview( if event.all_day: start_format = "%A, %d/%m/%Y" end_format = "" + waze_link = get_waze_link(event) + event_considering_privacy = event_to_show(event, db) + if not event_considering_privacy: + raise_for_nonexisting_event(event.id) messages = request.query_params.get("messages", "").split("---") return templates.TemplateResponse( "eventview.html", { "request": request, - "event": event, + "waze_link": waze_link, + "event": event_considering_privacy, "comments": comments, "start_format": start_format, "end_format": end_format, @@ -166,6 +290,46 @@ async def eventview( ) +def check_event_owner( + event: Event, + session: Depends(get_db), + user: Optional[User] = None, +) -> bool: + # TODO use current_user after user system merge + if not user: + user = get_current_user(session) + is_owner = event.owner_id == user.id + return is_owner + + +def event_to_show( + event: Event, + session: Depends(get_db), + user: Optional[User] = None, +) -> Optional[Event]: + """Check the given event's privacy and return + event/fixed private event/ nothing (hidden) accordingly""" + is_owner = check_event_owner(event, session, user) + if event.privacy == PrivacyKinds.Private.name and not is_owner: + event_dict = event.__dict__.copy() + if event_dict.get("_sa_instance_state", None): + event_dict.pop("_sa_instance_state") + event_dict.pop("id") + private_event = Event(**event_dict) + private_event.title = PrivacyKinds.Private.name + private_event.content = PrivacyKinds.Private.name + private_event.location = PrivacyKinds.Private.name + private_event.color = Null + private_event.invitees = PrivacyKinds.Private.name + private_event.category_id = Null + private_event.emotion = Null + return private_event + elif event.privacy == PrivacyKinds.Hidden.name and not is_owner: + return + elif event.privacy == PrivacyKinds.Public.name or is_owner: + return event + + @router.post("/{event_id}/owner") async def change_owner( request: Request, @@ -213,12 +377,7 @@ def by_id(db: Session, event_id: int) -> Event: try: event = db.query(Event).filter_by(id=event_id).one() except NoResultFound: - error_message = f"Event ID does not exist. ID: {event_id}" - logger.exception(error_message) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=error_message, - ) + raise_for_nonexisting_event(event_id) except MultipleResultsFound: error_message = ( f"Multiple results found when getting event. Expected only one. " @@ -325,11 +484,16 @@ def create_event( content: Optional[str] = None, location: Optional[str] = None, vc_link: str = None, + latitude: Optional[str] = None, + longitude: Optional[str] = None, color: Optional[str] = None, invitees: List[str] = None, category_id: Optional[int] = None, availability: bool = True, is_google_event: bool = False, + shared_list: Optional[SharedList] = None, + privacy: str = PrivacyKinds.Public.name, + image: Optional[str] = None, ): """Creates an event and an association.""" @@ -341,17 +505,22 @@ def create_event( title=title, start=start, end=end, + privacy=privacy, content=content, owner_id=owner_id, location=location, + latitude=latitude, + longitude=longitude, vc_link=vc_link, color=color, emotion=get_emotion(title, content), invitees=invitees_concatenated, all_day=all_day, category_id=category_id, + shared_list=shared_list, availability=availability, is_google_event=is_google_event, + image=image, ) create_model(db, UserEvent, user_id=owner_id, event_id=event.id) return event @@ -448,6 +617,60 @@ def add_new_event(values: dict, db: Session) -> Optional[Event]: return None +def extract_shared_list_from_data( + event_info: ImmutableMultiDict, + db: Session, +) -> Optional[SharedList]: + """Extract shared list items from POST data. + Return: + SharedList: SharedList object stored in the database. + """ + raw_items = zip( + event_info.getlist("item-name"), + event_info.getlist("item-amount"), + event_info.getlist("item-participant"), + ) + items = [] + title = event_info.get("shared-list-title") + for name, amount, participant in raw_items: + item = SharedItem(name, amount, participant) + if _check_item_is_valid(item): + item_dict = item._asdict() + item_dict["amount"] = float(item_dict["amount"]) + items.append(item_dict) + return _create_shared_list({title: items}, db) + + +def _check_item_is_valid(item: SharedItem) -> bool: + return ( + item is not None + and item.amount.isnumeric() + and item.participant is not None + ) + + +def _create_shared_list( + raw_shared_list: Dict[str, Dict[str, Any]], + db: Session, +) -> Optional[SharedList]: + try: + title = list(raw_shared_list.keys())[0] or "Shared List" + except IndexError as e: + logger.exception(e) + return None + shared_list = create_model(db, SharedList, title=title) + try: + items = list(raw_shared_list.values())[0] + for item in items: + item = create_model(db, SharedListItem, **item) + shared_list.items.append(item) + except (IndexError, KeyError) as e: + logger.exception(e) + return None + else: + return shared_list + + def get_template_to_share_event( event_id: int, user_name: str, @@ -529,11 +752,13 @@ async def view_comments( This essentially the same as `eventedit`, only with comments tab auto showed.""" event, comments, end_format = get_event_data(db, event_id) + waze_link = get_waze_link(event) return templates.TemplateResponse( "eventview.html", { "request": request, "event": event, + "waze_link": waze_link, "comments": comments, "comment": True, "start_format": START_FORMAT, @@ -563,3 +788,23 @@ async def delete_comment( cmt.delete_comment(db, comment_id) path = router.url_path_for("view_comments", event_id=str(event_id)) return RedirectResponse(path, status_code=303) + + +@router.get("/timezone/country/{country_name}", include_in_schema=False) +async def check_timezone( + country_name, + request: Request, + db_session: Session = Depends(get_db), +) -> Response: + try: + country_timezone = ( + db_session.query(Country.timezone) + .filter_by(name=country_name) + .first()[0] + ) + except TypeError: + raise HTTPException( + status_code=404, + detail="The inserted country name is not found", + ) + return {"timezone": country_timezone} diff --git a/app/routers/event_images.py b/app/routers/event_images.py index 9a1ae495..bfe56cd3 100644 --- a/app/routers/event_images.py +++ b/app/routers/event_images.py @@ -1,5 +1,5 @@ -from functools import lru_cache import re +from functools import lru_cache from typing import Optional from nltk.tokenize import word_tokenize @@ -7,135 +7,135 @@ from app import config -FLAIRS_EXTENSION = '.jpg' -FLAIRS_REL_PATH = f'{config.STATIC_ABS_PATH}\\event_flairs' +FLAIRS_EXTENSION = ".jpg" +FLAIRS_REL_PATH = f"{config.STATIC_ABS_PATH}\\event_flairs" IMAGES_RELATED_WORDS_MAP = { - 'birthday': 'birthday', - 'coffee': 'coffee', - 'coffees': 'coffee', - 'concert': 'concert', - 'gig': 'concert', - 'concerts': 'concert', - 'gigs': 'concert', - 'bicycle': 'cycle', - 'cycling': 'cycle', - 'bike': 'cycle', - 'bicycles': 'cycle', - 'bikes': 'cycle', - 'biking': 'cycle', - 'dentist': 'dentist', - 'dentistry': 'dentist', - 'dental': 'dentist', - 'dinner': 'food', - 'dinners': 'food', - 'restaurant': 'food', - 'restaurants': 'food', - 'family meal': 'food', - 'lunch': 'food', - 'lunches': 'food', - 'luncheon': 'food', - 'cocktail': 'drank', - 'drinks': 'drank', - 'cocktails': 'drank', - 'golf': 'golf', - 'graduation': 'graduate', - 'gym': 'gym', - 'workout': 'gym', - 'workouts': 'gym', - 'haircut': 'haircut', - 'hair': 'haircut', - 'halloween': 'halloween', - 'helloween': 'halloween', - "hallowe'en": 'halloween', - 'allhalloween': 'halloween', - "all hallows' eve": 'halloween', - "all saints' Eve": 'halloween', - 'hiking': 'hike', - 'hike': 'hike', - 'hikes': 'hike', - 'kayaking': 'kayak', - 'piano': 'music', - 'singing': 'music', - 'music class': 'music', - 'choir practice': 'music', - 'flute': 'music', - 'orchestra': 'music', - 'oboe': 'music', - 'clarinet': 'music', - 'saxophone': 'music', - 'cornett': 'music', - 'trumpet': 'music', - 'contrabass': 'music', - 'cello': 'music', - 'trombone': 'music', - 'tuba': 'music', - 'music ensemble': 'music', - 'string quartett': 'music', - 'guitar lesson': 'music', - 'classical music': 'music', - 'choir': 'music', - 'manicure': 'manicure', - 'pedicure': 'manicure', - 'manicures': 'manicure', - 'pedicures': 'manicure', - 'massage': 'massage', - 'back rub': 'massage', - 'backrub': 'massage', - 'massages': 'massage', - 'pills': 'pill', - 'medicines': 'pill', - 'medicine': 'pill', - 'drug': 'pill', - 'drugs': 'pill', - 'ping pong': 'pingpong', - 'table tennis': 'pingpong', - 'ping-pong': 'pingpong', - 'pingpong': 'pingpong', - 'plan week': 'plan', - 'plan quarter': 'plan', - 'plan day': 'plan', - 'plan vacation': 'plan', - 'week planning': 'plan', - 'vacation planning': 'plan', - 'pokemon': 'pokemon', - 'reading': 'read', - 'newspaper': 'read', - 'fridge repair': 'repair', - 'handyman': 'repair', - 'electrician': 'repair', - 'diy': 'repair', - 'jog': 'ran', - 'jogging': 'ran', - 'running': 'ran', - 'jogs': 'ran', - 'runs': 'ran', - 'sail': 'sail', - 'sailing': 'sail', - 'boat cruise': 'sail', - 'sailboat': 'sail', - 'santa claus': 'santa', - 'father christmas': 'santa', - 'skiing': 'ski', - 'ski': 'ski', - 'skis': 'ski', - 'snowboarding': 'ski', - 'snowshoeing': 'ski', - 'snow shoe': 'ski', - 'snow boarding': 'ski', - 'soccer': 'soccer', - 'swim': 'swam', - 'swimming': 'swam', - 'swims': 'swam', - 'tennis': 'tennis', - 'thanksgiving': 'thanksgiving', - 'wedding': 'wed', - 'wedding eve': 'wed', - 'wedding-eve party': 'wed', - 'weddings': 'wed', - 'christmas': 'christmas', - 'xmas': 'christmas', - 'x-mas': 'christmas', - 'yoga': 'yoga', + "birthday": "birthday", + "coffee": "coffee", + "coffees": "coffee", + "concert": "concert", + "gig": "concert", + "concerts": "concert", + "gigs": "concert", + "bicycle": "cycle", + "cycling": "cycle", + "bike": "cycle", + "bicycles": "cycle", + "bikes": "cycle", + "biking": "cycle", + "dentist": "dentist", + "dentistry": "dentist", + "dental": "dentist", + "dinner": "food", + "dinners": "food", + "restaurant": "food", + "restaurants": "food", + "family meal": "food", + "lunch": "food", + "lunches": "food", + "luncheon": "food", + "cocktail": "drank", + "drinks": "drank", + "cocktails": "drank", + "golf": "golf", + "graduation": "graduate", + "gym": "gym", + "workout": "gym", + "workouts": "gym", + "haircut": "haircut", + "hair": "haircut", + "halloween": "halloween", + "helloween": "halloween", + "hallowe'en": "halloween", + "allhalloween": "halloween", + "all hallows' eve": "halloween", + "all saints' Eve": "halloween", + "hiking": "hike", + "hike": "hike", + "hikes": "hike", + "kayaking": "kayak", + "piano": "music", + "singing": "music", + "music class": "music", + "choir practice": "music", + "flute": "music", + "orchestra": "music", + "oboe": "music", + "clarinet": "music", + "saxophone": "music", + "cornett": "music", + "trumpet": "music", + "contrabass": "music", + "cello": "music", + "trombone": "music", + "tuba": "music", + "music ensemble": "music", + "string quartett": "music", + "guitar lesson": "music", + "classical music": "music", + "choir": "music", + "manicure": "manicure", + "pedicure": "manicure", + "manicures": "manicure", + "pedicures": "manicure", + "massage": "massage", + "back rub": "massage", + "backrub": "massage", + "massages": "massage", + "pills": "pill", + "medicines": "pill", + "medicine": "pill", + "drug": "pill", + "drugs": "pill", + "ping pong": "pingpong", + "table tennis": "pingpong", + "ping-pong": "pingpong", + "pingpong": "pingpong", + "plan week": "plan", + "plan quarter": "plan", + "plan day": "plan", + "plan vacation": "plan", + "week planning": "plan", + "vacation planning": "plan", + "pokemon": "pokemon", + "reading": "read", + "newspaper": "read", + "fridge repair": "repair", + "handyman": "repair", + "electrician": "repair", + "diy": "repair", + "jog": "ran", + "jogging": "ran", + "running": "ran", + "jogs": "ran", + "runs": "ran", + "sail": "sail", + "sailing": "sail", + "boat cruise": "sail", + "sailboat": "sail", + "santa claus": "santa", + "father christmas": "santa", + "skiing": "ski", + "ski": "ski", + "skis": "ski", + "snowboarding": "ski", + "snowshoeing": "ski", + "snow shoe": "ski", + "snow boarding": "ski", + "soccer": "soccer", + "swim": "swam", + "swimming": "swam", + "swims": "swam", + "tennis": "tennis", + "thanksgiving": "thanksgiving", + "wedding": "wed", + "wedding eve": "wed", + "wedding-eve party": "wed", + "weddings": "wed", + "christmas": "christmas", + "xmas": "christmas", + "x-mas": "christmas", + "yoga": "yoga", } @@ -148,7 +148,7 @@ def generate_flare_link_from_lemmatized_word(lemmatized_word: str) -> str: Returns: str: The suitable link. """ - return f'{FLAIRS_REL_PATH}\\{lemmatized_word}{FLAIRS_EXTENSION}' + return f"{FLAIRS_REL_PATH}\\{lemmatized_word}{FLAIRS_EXTENSION}" def remove_non_alphabet_chars(text: str) -> str: @@ -160,8 +160,8 @@ def remove_non_alphabet_chars(text: str) -> str: Returns: str: The string after the removal. """ - regex = re.compile('[^a-zA-Z]') - return regex.sub('', text) + regex = re.compile("[^a-zA-Z]") + return regex.sub("", text) def get_image_name(related_word: str) -> Optional[str]: @@ -213,5 +213,5 @@ def attach_image_to_event(event_content: str) -> str: link = search_token_in_related_words(token) if link: return link - link = '#' + link = "#" return link diff --git a/app/routers/export.py b/app/routers/export.py index a5fd4229..baa098a6 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -9,7 +9,8 @@ from app.dependencies import get_db from app.internal.agenda_events import get_events_in_time_frame from app.internal.export import get_icalendar_with_multiple_events -from app.internal.utils import get_current_user +from app.internal.security.schema import CurrentUser +from tests.security_testing_routes import current_user router = APIRouter( prefix="/export", @@ -20,29 +21,27 @@ @router.get("/") def export( - start_date: Union[date, str], - end_date: Union[date, str], - db: Session = Depends(get_db), + start_date: Union[date, str], + end_date: Union[date, str], + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), ) -> StreamingResponse: """Returns the Export page route. - Args: start_date: A date or an empty string. end_date: A date or an empty string. db: Optional; The database connection. - + user: user schema object. Returns: - # TODO add description + A StreamingResponse that contains an .ics file. """ - # TODO: connect to real user - user = get_current_user(db) - events = get_events_in_time_frame(start_date, end_date, user.id, db) + events = get_events_in_time_frame(start_date, end_date, user.user_id, db) file = BytesIO(get_icalendar_with_multiple_events(db, list(events))) return StreamingResponse( content=file, media_type="text/calendar", headers={ - # Change filename to "pylandar.ics". - "Content-Disposition": "attachment;filename=pylandar.ics", + # Change filename to "PyLendar.ics". + "Content-Disposition": "attachment;filename=PyLendar.ics", }, ) diff --git a/app/routers/features.py b/app/routers/features.py new file mode 100644 index 00000000..1694afe3 --- /dev/null +++ b/app/routers/features.py @@ -0,0 +1,111 @@ +from typing import List + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.sql import exists + +from app.database.models import Feature, User, UserFeature +from app.dependencies import SessionLocal, get_db +from app.internal.features import ( + create_user_feature_association, + get_user_installed_features, + get_user_uninstalled_features, + is_user_has_feature, +) +from app.internal.security.dependencies import current_user + +router = APIRouter( + prefix="/features", + tags=["features"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +async def index( + request: Request, + session: SessionLocal = Depends(get_db), +) -> List[Feature]: + features = session.query(Feature).all() + return features + + +@router.post("/add") +async def add_feature_to_user( + request: Request, + session: SessionLocal = Depends(get_db), + user: User = Depends(current_user), +) -> bool: + form = await request.form() + + feat = session.query( + exists().where(Feature.id == form["feature_id"]), + ).scalar() + + is_exist = is_user_has_feature( + session=session, + feature_id=form["feature_id"], + user_id=user.user_id, + ) + + if not feat or is_exist: + # in case there is no feature in the database with that same id + # and or the association is exist + return False + + create_user_feature_association( + db=session, + feature_id=form["feature_id"], + user_id=user.user_id, + is_enable=True, + ) + + return is_user_has_feature( + session=session, + feature_id=form["feature_id"], + user_id=user.user_id, + ) + + +@router.post("/delete") +async def delete_user_feature_association( + request: Request, + session: SessionLocal = Depends(get_db), + user: User = Depends(current_user), +) -> bool: + form = await request.form() + feature_id = int(form["feature_id"]) + + is_exist = is_user_has_feature( + session=session, + feature_id=feature_id, + user_id=user.user_id, + ) + + if not is_exist: + return False + + session.query(UserFeature).filter_by( + feature_id=feature_id, + user_id=user.user_id, + ).delete() + session.commit() + + return True + + +@router.get("/deactive") +def deactive( + request: Request, + session: SessionLocal = Depends(get_db), + user: User = Depends(current_user), +): + return get_user_uninstalled_features(user_id=user.user_id, session=session) + + +@router.get("/active") +def active( + request: Request, + session: SessionLocal = Depends(get_db), + user: User = Depends(current_user), +): + return get_user_installed_features(user_id=user.user_id, session=session) diff --git a/app/routers/four_o_four.py b/app/routers/four_o_four.py index 0e989677..5dd2fe47 100644 --- a/app/routers/four_o_four.py +++ b/app/routers/four_o_four.py @@ -1,7 +1,8 @@ -from app.dependencies import templates from fastapi import APIRouter from starlette.requests import Request +from app.dependencies import templates + router = APIRouter( prefix="/404", tags=["404"], @@ -11,5 +12,4 @@ @router.get("/") async def not_implemented(request: Request): - return templates.TemplateResponse("four_o_four.j2", - {"request": request}) + return templates.TemplateResponse("four_o_four.j2", {"request": request}) diff --git a/app/routers/friendview.py b/app/routers/friendview.py index 1ed86f8c..40c9fcfd 100644 --- a/app/routers/friendview.py +++ b/app/routers/friendview.py @@ -1,28 +1,31 @@ +from typing import Union + from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from starlette.templating import _TemplateResponse -from typing import Union from app.dependencies import get_db, templates from app.internal import friend_view - router = APIRouter(tags=["friendview"]) @router.get("/friendview") def friendview( - request: Request, - db: Session = Depends(get_db), - my_friend: Union[str, None] = None, + request: Request, + db: Session = Depends(get_db), + my_friend: Union[str, None] = None, ) -> _TemplateResponse: # TODO: Waiting for user registration user_id = 1 events_list = friend_view.get_events_per_friend(db, user_id, my_friend) - return templates.TemplateResponse("friendview.html", { - "request": request, - "events": events_list, - "my_friend": my_friend, - }) + return templates.TemplateResponse( + "friendview.html", + { + "request": request, + "events": events_list, + "my_friend": my_friend, + }, + ) diff --git a/app/routers/google_connect.py b/app/routers/google_connect.py index cbf79e18..6892cff9 100644 --- a/app/routers/google_connect.py +++ b/app/routers/google_connect.py @@ -1,10 +1,11 @@ -from fastapi import Depends, APIRouter, Request -from starlette.responses import RedirectResponse +from fastapi import APIRouter, Depends, Request from loguru import logger +from starlette.responses import RedirectResponse -from app.internal.utils import get_current_user from app.dependencies import get_db -from app.internal.google_connect import get_credentials, fetch_save_events +from app.internal.features import feature_access_filter +from app.internal.google_connect import fetch_save_events, get_credentials +from app.internal.utils import get_current_user from app.routers.profile import router as profile router = APIRouter( @@ -15,11 +16,14 @@ @router.get("/sync") -async def google_sync(request: Request, - session=Depends(get_db)) -> RedirectResponse: - '''Sync with Google - if user never synced with google this funcion will take +@feature_access_filter +async def google_sync( + request: Request, + session=Depends(get_db), +) -> RedirectResponse: + """Sync with Google - if user never synced with google this funcion will take the user to a consent screen to use his google calendar data with the app. - ''' + """ user = get_current_user(session) # getting active user @@ -33,5 +37,5 @@ async def google_sync(request: Request, # fetch and save the events com from Google Calendar fetch_save_events(credentials=credentials, user=user, session=session) - url = profile.url_path_for('profile') + url = profile.url_path_for("profile") return RedirectResponse(url=url) diff --git a/app/routers/invitation.py b/app/routers/invitation.py deleted file mode 100644 index da2ba209..00000000 --- a/app/routers/invitation.py +++ /dev/null @@ -1,110 +0,0 @@ -from typing import Any, List, Optional - -from fastapi import APIRouter, Depends, Request, status -from fastapi.responses import RedirectResponse, Response -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session - -from app.database.models import Invitation -from app.dependencies import get_db, templates -from app.routers.share import accept - -router = APIRouter( - prefix="/invitations", - tags=["invitation"], - dependencies=[Depends(get_db)], -) - - -@router.get("/", include_in_schema=False) -def view_invitations( - request: Request, db: Session = Depends(get_db) -) -> Response: - """Returns the Invitations page route. - - Args: - request: The HTTP request. - db: Optional; The database connection. - - Returns: - The Invitations HTML page. - """ - return templates.TemplateResponse("invitations.html", { - "request": request, - # TODO: Connect to current user. - # recipient_id should be the current user - # but because we don't have one yet, - # "get_all_invitations" returns all invitations - "invitations": get_all_invitations(db), - }) - - -@router.post("/", include_in_schema=False) -async def accept_invitations( - request: Request, db: Session = Depends(get_db) -) -> RedirectResponse: - """Creates a new connection between the User and the Event in the database. - - See Also: - share.accept for more information. - - Args: - request: The HTTP request. - db: Optional; The database connection. - - Returns: - An updated Invitations HTML page. - """ - data = await request.form() - invite_id = list(data.values())[0] - - invitation = get_invitation_by_id(invite_id, db) - if invitation: - accept(invitation, db) - - url = router.url_path_for("view_invitations") - return RedirectResponse(url=url, status_code=status.HTTP_302_FOUND) - - -# TODO: should be a get request with the path of: -# @router.get("/all") -@router.get("/get_all_invitations") -def get_all_invitations( - db: Session = Depends(get_db), **param: Any -) -> List[Invitation]: - """Returns all Invitations filtered by the requested parameters. - - Args: - db: Optional; The database connection. - **param: A list of parameters to filter by. - - Returns: - A list of all Invitations. - """ - try: - invitations = list(db.query(Invitation).filter_by(**param)) - except SQLAlchemyError: - return [] - else: - return invitations - - -# TODO: should be a get request with the path of: -# @router.get("/{id}") -@router.post("/get_invitation_by_id") -def get_invitation_by_id( - invitation_id: int, db: Session = Depends(get_db) -) -> Optional[Invitation]: - """Returns an Invitation by an ID. - - Args: - invitation_id: The Invitation ID. - db: Optional; The database connection. - - Returns: - An Invitation object if found, otherwise returns None. - """ - return (db.query(Invitation) - .filter_by(id=invitation_id) - .first() - ) diff --git a/app/routers/joke.py b/app/routers/joke.py new file mode 100644 index 00000000..f35dfae9 --- /dev/null +++ b/app/routers/joke.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.dependencies import get_db +from app.internal import jokes + +router = APIRouter() + + +@router.get("/joke") +async def joke(request: Request, db: Session = Depends(get_db)): + return jokes.get_a_joke(db) diff --git a/app/routers/login.py b/app/routers/login.py index 99fd5b5c..d4d11400 100644 --- a/app/routers/login.py +++ b/app/routers/login.py @@ -3,13 +3,11 @@ from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from starlette.responses import RedirectResponse -from starlette.status import HTTP_302_FOUND from app.dependencies import get_db, templates -from app.internal.security.ouath2 import ( - authenticate_user, create_jwt_token) from app.internal.security import schema - +from app.internal.security.ouath2 import authenticate_user, create_jwt_token +from app.internal.utils import safe_redirect_response router = APIRouter( prefix="", @@ -20,21 +18,23 @@ @router.get("/login") async def login_user_form( - request: Request, message: Optional[str] = "") -> templates: + request: Request, + message: Optional[str] = "", +) -> templates: """rendering login route get method""" - return templates.TemplateResponse("login.html", { - "request": request, - "message": message, - 'current_user': "logged in" - }) + return templates.TemplateResponse( + "login.html", + {"request": request, "message": message, "current_user": "logged in"}, + ) -@router.post('/login') +@router.post("/login") async def login( - request: Request, - next: Optional[str] = "/", - db: Session = Depends(get_db), - existing_jwt: Union[str, bool] = False) -> RedirectResponse: + request: Request, + next: Optional[str] = "/", + db: Session = Depends(get_db), + existing_jwt: Union[str, bool] = False, +) -> RedirectResponse: """rendering login route post method.""" form = await request.form() form_dict = dict(form) @@ -49,19 +49,17 @@ async def login( if user: user = await authenticate_user(db, user) if not user: - return templates.TemplateResponse("login.html", { - "request": request, - "message": 'Please check your credentials' - }) + return templates.TemplateResponse( + "login.html", + {"request": request, "message": "Please check your credentials"}, + ) # creating HTTPONLY cookie with jwt-token out of user unique data # for testing if not existing_jwt: jwt_token = create_jwt_token(user) else: jwt_token = existing_jwt - if not next.startswith("/"): - next = "/" - response = RedirectResponse(next, status_code=HTTP_302_FOUND) + response = safe_redirect_response(next) response.set_cookie( "Authorization", value=jwt_token, diff --git a/app/routers/logout.py b/app/routers/logout.py index 009de760..4f52825f 100644 --- a/app/routers/logout.py +++ b/app/routers/logout.py @@ -2,7 +2,6 @@ from starlette.responses import RedirectResponse from starlette.status import HTTP_302_FOUND - router = APIRouter( prefix="", tags=["/logout"], @@ -10,7 +9,7 @@ ) -@router.get('/logout') +@router.get("/logout") async def logout(request: Request): response = RedirectResponse(url="/login", status_code=HTTP_302_FOUND) response.delete_cookie("Authorization") diff --git a/app/routers/meds.py b/app/routers/meds.py new file mode 100644 index 00000000..d9449706 --- /dev/null +++ b/app/routers/meds.py @@ -0,0 +1,65 @@ +from datetime import date, time, timedelta + +from fastapi import APIRouter +from fastapi.param_functions import Depends +from sqlalchemy.orm.session import Session +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette.status import HTTP_303_SEE_OTHER + +from app.dependencies import get_db, templates +from app.internal import meds +from app.internal.utils import get_current_user +from app.main import app + +router = APIRouter( + prefix="/meds", + tags=["meds"], + dependencies=[Depends(get_db)], +) + + +@router.get("/") +@router.post("/") +async def medications( + request: Request, + session: Session = Depends(get_db), +) -> Response: + """Renders medication reminders creation form page. Creates reminders in DB + and redirects to home page upon submition if valid.""" + form = await request.form() + errors = [] + + form_data = { + "name": "", + "start": date.today(), + "first": None, + "end": date.today() + timedelta(days=7), + "amount": 1, + "early": time(8), + "late": time(22), + "min": time(0, 1), + "max": time(23, 59), + "note": "", + } + + if form: + form, form_data = meds.trans_form(form) + user = get_current_user(session) + errors = meds.validate_form(form) + if not errors: + meds.create_events(session, user.id, form) + return RedirectResponse( + app.url_path_for("home"), + status_code=HTTP_303_SEE_OTHER, + ) + + return templates.TemplateResponse( + "meds.j2", + { + "request": request, + "errors": errors, + "data": form_data, + "quantity": meds.MAX_EVENT_QUANTITY, + }, + ) diff --git a/app/routers/notes/__init__.py b/app/routers/notes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/routers/notes/notes.py b/app/routers/notes/notes.py new file mode 100644 index 00000000..fd2c38c3 --- /dev/null +++ b/app/routers/notes/notes.py @@ -0,0 +1,167 @@ +from typing import Any, Dict, List, Sequence + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.param_functions import Depends +from fastapi.responses import Response +from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse + +from app.database.schemas import NoteDB, NoteSchema +from app.dependencies import get_db, templates +from app.internal import utils +from app.internal.notes import notes + +router = APIRouter( + prefix="/notes", + tags=["notes"], + responses={404: {"description": "Not found"}}, +) + + +@router.post( + "/edit/{note_id}", + status_code=status.HTTP_202_ACCEPTED, + include_in_schema=False, +) +async def redirect_update_note( + request: Request, + note_id: int, + session: Session = Depends(get_db), +) -> RedirectResponse: + """Update a note using user-interface form.""" + form = await request.form() + updated_note = NoteSchema(**dict(form)) + await update_note(updated_note, note_id, session) + return RedirectResponse("/notes", status_code=status.HTTP_302_FOUND) + + +@router.post( + "/delete/{note_id}", + status_code=status.HTTP_200_OK, + include_in_schema=False, +) +async def redirect_delete_note( + note_id: int, + session: Session = Depends(get_db), +) -> RedirectResponse: + """Delete a note from the database using user-interface form.""" + await delete_note(note_id, session) + return RedirectResponse("/notes", status_code=status.HTTP_302_FOUND) + + +@router.post( + "/add", + status_code=status.HTTP_201_CREATED, + include_in_schema=False, +) +async def create_note_by_form( + request: Request, + session: Session = Depends(get_db), +) -> RedirectResponse: + """Add a note using user-interface form.""" + form = await request.form() + new_note = NoteSchema(**dict(form)) + new_note.creator = utils.get_current_user(session) + await notes.create_note(note=new_note, session=session) + return RedirectResponse("/notes", status_code=status.HTTP_302_FOUND) + + +@router.post("/", response_model=NoteDB, status_code=status.HTTP_201_CREATED) +async def create_new_note( + request: NoteSchema, + session: Session = Depends(get_db), +) -> Dict[str, Any]: + """Create a note in the database.""" + new_note = NoteSchema(**dict(request)) + new_note.creator = utils.get_current_user(session) + return await notes.create_note(note=new_note, session=session) + + +@router.delete("/{note_id}/", status_code=status.HTTP_200_OK) +async def delete_note(note_id: int, session: Session = Depends(get_db)) -> str: + """Delete a note by its identifier.""" + return await notes.delete(session, note_id) + + +@router.put("/{note_id}/", status_code=status.HTTP_202_ACCEPTED) +async def update_note( + request: NoteSchema, + note_id: int, + session: Session = Depends(get_db), +) -> str: + """Update a note by providing its identifier and the changed json data.""" + return await notes.update(request, session, note_id) + + +@router.get("/view/{note_id}", include_in_schema=False) +async def view_note( + request: Request, + note_id: int, + session: Session = Depends(get_db), +) -> Response: + """View a note for update using user interface.""" + note = await notes.view(session, note_id) + return templates.TemplateResponse( + "notes/note_view.html", + {"request": request, "data": note}, + ) + + +@router.get("/delete/{note_id}", include_in_schema=False) +async def remove_note( + request: Request, + note_id: int, + session: Session = Depends(get_db), +) -> Response: + """View a note for delete using user interface.""" + note = await notes.view(session, note_id) + return templates.TemplateResponse( + "notes/note_delete.html", + {"request": request, "data": note}, + ) + + +@router.get("/add", include_in_schema=False) +async def create_note_form(request: Request) -> Response: + """View form for creating a new note.""" + return templates.TemplateResponse("notes/note.html", {"request": request}) + + +@router.get("/all", response_model=List[NoteDB]) +async def get_all_notes( + session: Session = Depends(get_db), +) -> Sequence[NoteDB]: + """View all notes in the database.""" + return await notes.get_all(session) + + +@router.get( + "/{note_id}/", + status_code=status.HTTP_200_OK, + response_model=NoteDB, +) +async def read_note( + note_id: int, + session: Session = Depends(get_db), +) -> NoteDB: + """View a note by its identifier.""" + note = await notes.view(session, note_id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Note with id {note_id} not found", + ) + return note + + +@router.get("/", include_in_schema=False) +async def view_notes( + request: Request, + session: Session = Depends(get_db), +) -> Response: + """View all notes in the database using user interface.""" + data = await notes.get_all(session) + return templates.TemplateResponse( + "notes/notes.html", + {"request": request, "data": data}, + ) diff --git a/app/routers/notification.py b/app/routers/notification.py new file mode 100644 index 00000000..540016a5 --- /dev/null +++ b/app/routers/notification.py @@ -0,0 +1,191 @@ +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm import Session + +from app.database.models import MessageStatusEnum +from app.dependencies import get_db, templates +from app.internal.notification import ( + get_all_messages, + get_archived_notifications, + get_invitation_by_id, + get_message_by_id, + get_unread_notifications, + is_owner, + raise_wrong_id_error, +) +from app.internal.security.dependencies import current_user, is_logged_in +from app.internal.security.schema import CurrentUser +from app.internal.utils import safe_redirect_response + +router = APIRouter( + prefix="/notification", + tags=["notification"], + dependencies=[ + Depends(get_db), + Depends(is_logged_in), + ], +) + + +@router.get("/", include_in_schema=False) +async def view_notifications( + request: Request, + user: CurrentUser = Depends(current_user), + db: Session = Depends(get_db), +): + """Returns the Notifications page. + + Args: + request: The HTTP request. + db: Optional; The database connection. + user: user schema object. + + Returns: + The Notifications HTML page. + """ + return templates.TemplateResponse( + "notifications.html", + { + "request": request, + "new_messages": bool(get_all_messages), + "notifications": list( + get_unread_notifications( + session=db, + user_id=user.user_id, + ), + ), + }, + ) + + +@router.get("/archive", include_in_schema=False) +async def view_archive( + request: Request, + user: CurrentUser = Depends(current_user), + db: Session = Depends(get_db), +): + """Returns the Archived Notifications page. + + Args: + request: The HTTP request. + db: Optional; The database connection. + user: user schema object. + + Returns: + The Archived Notifications HTML page. + """ + return templates.TemplateResponse( + "archive.html", + { + "request": request, + "notifications": list( + get_archived_notifications( + session=db, + user_id=user.user_id, + ), + ), + }, + ) + + +@router.post("/invitation/accept") +async def accept_invitations( + invite_id: int = Form(...), + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Creates a new connection between the User and the Event in the database. + + See Also: + models.Invitation.accept for more information. + + Args: + invite_id: the id of the invitation. + next_url: url to redirect to. + db: Optional; The database connection. + user: user schema object. + + Returns: + A redirect to where the user called the route from. + """ + invitation = get_invitation_by_id(invite_id, session=db) + if invitation and is_owner(user, invitation): + invitation.accept(db) + return safe_redirect_response(next_url) + + raise_wrong_id_error() + + +@router.post("/invitation/decline") +async def decline_invitations( + invite_id: int = Form(...), + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Declines an invitations. + + Args: + invite_id: the id of the invitation. + db: Optional; The database connection. + next_url: url to redirect to. + user: user schema object. + + Returns: + A redirect to where the user called the route from. + """ + invitation = get_invitation_by_id(invite_id, session=db) + if invitation and is_owner(user, invitation): + invitation.decline(db) + return safe_redirect_response(next_url) + + raise_wrong_id_error() + + +@router.post("/message/read") +async def mark_message_as_read( + message_id: int = Form(...), + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Marks a message as read. + + Args: + message_id: the id of the message. + db: Optional; The database connection. + next_url: url to redirect to. + user: user schema object. + + Returns: + A redirect to where the user called the route from. + """ + message = await get_message_by_id(message_id, session=db) + if message and is_owner(user, message): + message.mark_as_read(db) + return safe_redirect_response(next_url) + + raise_wrong_id_error() + + +@router.post("/message/read/all") +async def mark_all_as_read( + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Marks all messages as read. + + Args: + next_url: url to redirect to. + user: user schema object. + db: Optional; The database connection. + + Returns: + A redirect to where the user called the route from. + """ + for message in get_all_messages(db, user.user_id): + if message.status == MessageStatusEnum.UNREAD: + message.mark_as_read(db) + + return safe_redirect_response(next_url) diff --git a/app/routers/profile.py b/app/routers/profile.py index caa774d1..d69822dc 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -1,21 +1,28 @@ import io -from loguru import logger from fastapi import APIRouter, Depends, File, Request, UploadFile +from loguru import logger from PIL import Image +from sqlalchemy.exc import SQLAlchemyError from starlette.responses import RedirectResponse from starlette.status import HTTP_302_FOUND -from sqlalchemy.exc import SQLAlchemyError from app import config from app.database.models import User -from app.dependencies import get_db, MEDIA_PATH, templates, GOOGLE_ERROR +from app.dependencies import GOOGLE_ERROR, MEDIA_PATH, get_db, templates +from app.internal.corona_stats import get_corona_stats +from app.internal.import_holidays import ( + get_holidays_from_file, + save_holidays_to_db, +) from app.internal.on_this_day_events import get_on_this_day_events -from app.internal.import_holidays import (get_holidays_from_file, - save_holidays_to_db) +from app.internal.privacy import PrivacyKinds +from app.internal.showevent import get_upcoming_events PICTURE_EXTENSION = config.PICTURE_EXTENSION PICTURE_SIZE = config.AVATAR_SIZE +FIVE_EVENTS = 5 +# We are presenting up to five upcoming events on the profile page router = APIRouter( prefix="/profile", @@ -26,49 +33,66 @@ def get_placeholder_user(): return User( - username='new_user', - email='my@email.po', - password='1a2s3d4f5g6', - full_name='My Name', + username="new_user", + email="my@email.po", + password="1a2s3d4f5g6", + full_name="My Name", language_id=1, - telegram_id='', + telegram_id="", ) @router.get("/") async def profile( - request: Request, - session=Depends(get_db), - new_user=Depends(get_placeholder_user)): - # Get relevant data from database - upcoming_events = range(5) + request: Request, + session=Depends(get_db), + new_user=Depends(get_placeholder_user), +): user = session.query(User).filter_by(id=1).first() if not user: session.add(new_user) session.commit() user = session.query(User).filter_by(id=1).first() + upcoming_events = get_upcoming_events(session, user.id)[:FIVE_EVENTS] + + signs = [ + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", + ] - signs = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', - 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', - 'Capricorn', 'Aquarius', 'Pisces'] on_this_day_data = get_on_this_day_events(session) - - return templates.TemplateResponse("profile.html", { - "request": request, - "user": user, - "events": upcoming_events, - "signs": signs, - 'google_error': GOOGLE_ERROR, - "on_this_day_data": on_this_day_data, - }) + corona_stats_data = await get_corona_stats(session) + + return templates.TemplateResponse( + "profile.html", + { + "request": request, + "user": user, + "events": upcoming_events, + "signs": signs, + "google_error": GOOGLE_ERROR, + "on_this_day_data": on_this_day_data, + "corona_stats_data": corona_stats_data, + "privacy": PrivacyKinds, + }, + ) @router.post("/update_user_fullname") -async def update_user_fullname( - request: Request, session=Depends(get_db)): +async def update_user_fullname(request: Request, session=Depends(get_db)): user = session.query(User).filter_by(id=1).first() data = await request.form() - new_fullname = data['fullname'] + new_fullname = data["fullname"] # Update database user.full_name = new_fullname @@ -79,11 +103,10 @@ async def update_user_fullname( @router.post("/update_user_email") -async def update_user_email( - request: Request, session=Depends(get_db)): +async def update_user_email(request: Request, session=Depends(get_db)): user = session.query(User).filter_by(id=1).first() data = await request.form() - new_email = data['email'] + new_email = data["email"] # Update database user.email = new_email @@ -94,11 +117,10 @@ async def update_user_email( @router.post("/update_user_description") -async def update_profile( - request: Request, session=Depends(get_db)): +async def update_profile(request: Request, session=Depends(get_db)): user = session.query(User).filter_by(id=1).first() data = await request.form() - new_description = data['description'] + new_description = data["description"] # Update database user.description = new_description @@ -110,7 +132,9 @@ async def update_profile( @router.post("/upload_user_photo") async def upload_user_photo( - file: UploadFile = File(...), session=Depends(get_db)): + file: UploadFile = File(...), + session=Depends(get_db), +): user = session.query(User).filter_by(id=1).first() pic = await file.read() @@ -125,11 +149,10 @@ async def upload_user_photo( @router.post("/update_telegram_id") -async def update_telegram_id( - request: Request, session=Depends(get_db)): +async def update_telegram_id(request: Request, session=Depends(get_db)): user = session.query(User).filter_by(id=1).first() data = await request.form() - new_telegram_id = data['telegram_id'] + new_telegram_id = data["telegram_id"] # Update database user.telegram_id = new_telegram_id @@ -140,13 +163,10 @@ async def update_telegram_id( @router.post("/privacy") -async def update_calendar_privacy( - request: Request, - session=Depends(get_db) -): +async def update_calendar_privacy(request: Request, session=Depends(get_db)): user = session.query(User).filter_by(id=1).first() data = await request.form() - new_privacy = data['privacy'] + new_privacy = data["privacy"] # Update database user.privacy = new_privacy @@ -158,9 +178,12 @@ async def update_calendar_privacy( @router.get("/holidays/import") def import_holidays(request: Request): - return templates.TemplateResponse("import_holidays.html", { - "request": request, - }) + return templates.TemplateResponse( + "import_holidays.html", + { + "request": request, + }, + ) async def process_image(image, user): @@ -183,8 +206,7 @@ def get_image_crop_area(width, height): @router.post("/holidays/update") -async def update( - file: UploadFile = File(...), session=Depends(get_db)): +async def update(file: UploadFile = File(...), session=Depends(get_db)): icsfile = await file.read() holidays = get_holidays_from_file(icsfile.decode(), session) try: diff --git a/app/routers/register.py b/app/routers/register.py index 57f77165..f66661e1 100644 --- a/app/routers/register.py +++ b/app/routers/register.py @@ -7,11 +7,9 @@ from starlette.status import HTTP_302_FOUND from starlette.templating import _TemplateResponse -from app.internal.security.ouath2 import get_hashed_password from app.database import schemas -from app.database import models from app.dependencies import get_db, templates - +from app.internal.user.user import check_unique_fields, create_user router = APIRouter( prefix="", @@ -20,58 +18,6 @@ ) -async def create_user(db: Session, user: schemas.UserCreate) -> models.User: - """ - creating a new User object in the database, with hashed password - """ - unhashed_password = user.password.encode("utf-8") - hashed_password = get_hashed_password(unhashed_password) - user_details = { - "username": user.username, - "full_name": user.full_name, - "email": user.email, - "password": hashed_password, - "description": user.description, - } - db_user = models.User(**user_details) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user - - -async def check_unique_fields( - db: Session, - new_user: schemas.UserCreate, -) -> dict: - """Verifying new user details are unique. Return relevant errors""" - errors = {} - if db.query( - db.query(models.User) - .filter(models.User.username == new_user.username) - .exists(), - ).scalar(): - errors["username"] = "That username is already taken" - if db.query( - db.query(models.User) - .filter(models.User.email == new_user.email) - .exists(), - ).scalar(): - errors["email"] = "Email already registered" - return errors - - -def get_error_messages_by_fields( - errors: List[Dict[str, Any]], -) -> Dict[str, str]: - """Getting validation errors by fields from pydantic ValidationError""" - errors_by_fields = {error["loc"][0]: error["msg"] for error in errors} - return { - field_name: f"{field_name.capitalize()} {error_message}" - for field_name, error_message in errors_by_fields.items() - } - - @router.get("/register") async def register_user_form(request: Request) -> _TemplateResponse: """rendering register route get method""" @@ -108,3 +54,14 @@ async def register( ) await create_user(db=db, user=new_user) return RedirectResponse("/profile", status_code=HTTP_302_FOUND) + + +def get_error_messages_by_fields( + errors: List[Dict[str, Any]], +) -> Dict[str, str]: + """Getting validation errors by fields from pydantic ValidationError""" + errors_by_fields = {error["loc"][0]: error["msg"] for error in errors} + return { + field_name: f"{field_name.capitalize()} {error_message}" + for field_name, error_message in errors_by_fields.items() + } diff --git a/app/routers/reset_password.py b/app/routers/reset_password.py new file mode 100644 index 00000000..9a8d3444 --- /dev/null +++ b/app/routers/reset_password.py @@ -0,0 +1,137 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import ValidationError +from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse +from starlette.status import HTTP_302_FOUND + +from app.dependencies import get_db, templates +from app.internal.email import BackgroundTasks, send_reset_password_mail +from app.internal.security.ouath2 import ( + create_jwt_token, + get_jwt_token, + is_email_compatible_to_username, + update_password, +) +from app.internal.security.schema import ForgotPassword, ResetPassword +from app.routers.login import router as login_router + +router = APIRouter( + prefix="", + tags=["/reset_password"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/forgot-password") +async def forgot_password_form(request: Request) -> templates: + """rendering forgot password form get method""" + return templates.TemplateResponse( + "forgot_password.html", + { + "request": request, + }, + ) + + +@router.post("/forgot-password") +async def forgot_password( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +) -> templates: + """ + Validaiting form data fields. + Validaiting form data against database records. + If all validations succeed, creating jwt token, + then sending email to the user with a reset password route link. + The contains the verafiction jwt token. + """ + form = await request.form() + form_dict = dict(form) + form_dict["username"] = "@" + form_dict["username"] + try: + # validating form data by creating pydantic schema object + user = ForgotPassword(**form_dict) + except ValidationError: + return templates.TemplateResponse( + "forgot_password.html", + {"request": request, "message": "Please check your credentials"}, + ) + user = await is_email_compatible_to_username(db, user) + if not user: + return templates.TemplateResponse( + "forgot_password.html", + {"request": request, "message": "Please check your credentials"}, + ) + user.email_verification_token = create_jwt_token(user, jwt_min_exp=15) + await send_reset_password_mail(user, background_tasks) + return templates.TemplateResponse( + "forgot_password.html", + { + "request": request, + "message": "Email for reseting password was sent", + }, + ) + + +@router.get("/reset-password") +async def reset_password_form( + request: Request, + email_verification_token: Optional[str] = "", +) -> templates: + """ + Rendering reset password form get method. + Validating jwt token is supplied with request. + """ + if email_verification_token: + return templates.TemplateResponse( + "reset_password.html", + { + "request": request, + }, + ) + message = "?message=Verification token is missing" + return RedirectResponse( + login_router.url_path_for("login_user_form") + f"{message}", + status_code=HTTP_302_FOUND, + ) + + +@router.post("/reset-password") +async def reset_password( + request: Request, + email_verification_token: str = "", + db: Session = Depends(get_db), +) -> RedirectResponse: + """ + Receives email verification jwt token. + Receives form data, and validates all fields are correct. + Validating token. + validatting form data against token details. + If all validations succeed, hashing new password and updating database. + """ + jwt_payload = get_jwt_token(email_verification_token) + jwt_username = jwt_payload.get("sub").strip("@") + form = await request.form() + form_dict = dict(form) + validated = True + if not form_dict["username"] == jwt_username: + validated = False + try: + # validating form data by creating pydantic schema object + user = ResetPassword(**form_dict) + except ValueError: + validated = False + if not validated: + return templates.TemplateResponse( + "reset_password.html", + {"request": request, "message": "Please check your credentials"}, + ) + await update_password(db, jwt_username, user.password) + message = "?message=Success reset password" + return RedirectResponse( + login_router.url_path_for("login_user_form") + str(message), + status_code=HTTP_302_FOUND, + ) diff --git a/app/routers/salary/config.py b/app/routers/salary/config.py index e7587207..490e9156 100644 --- a/app/routers/salary/config.py +++ b/app/routers/salary/config.py @@ -20,5 +20,3 @@ HOURS_SECONDS_RATIO = 3600 NUMERIC = Union[float, int] -HOUR_FORMAT = '%H:%M:%S' -ALT_HOUR_FORMAT = '%H:%M' diff --git a/app/routers/salary/routes.py b/app/routers/salary/routes.py index 72a8d262..bd5966cd 100644 --- a/app/routers/salary/routes.py +++ b/app/routers/salary/routes.py @@ -8,7 +8,8 @@ from app.database.models import SalarySettings from app.dependencies import get_db, templates -from app.internal.utils import create_model, get_current_user +from app.internal.utils import (create_model, get_current_user, + get_time_from_string) from app.routers.salary import utils router = APIRouter( @@ -111,9 +112,9 @@ async def create_settings(request: Request, 'holiday_category_id': form['holiday_category_id'], 'regular_hour_basis': form['regular_hour_basis'], 'night_hour_basis': form['night_hour_basis'], - 'night_start': utils.get_time_from_string(form['night_start']), - 'night_end': utils.get_time_from_string(form['night_end']), - 'night_min_len': utils.get_time_from_string(form['night_min_len']), + 'night_start': get_time_from_string(form['night_start']), + 'night_end': get_time_from_string(form['night_end']), + 'night_min_len': get_time_from_string(form['night_min_len']), 'first_overtime_amount': form['first_overtime_amount'], 'first_overtime_pay': form['first_overtime_pay'], 'second_overtime_pay': form['second_overtime_pay'], diff --git a/app/routers/salary/utils.py b/app/routers/salary/utils.py index aa922ed2..aadc1533 100644 --- a/app/routers/salary/utils.py +++ b/app/routers/salary/utils.py @@ -4,7 +4,7 @@ from sqlalchemy.orm.session import Session from app.database.models import Event, SalarySettings -from app.internal.utils import save +from app.internal.utils import get_time_from_string, save from app.routers.salary import config DEFAULT_SETTINGS = SalarySettings( @@ -58,7 +58,7 @@ def get_night_times(date: datetime, wage: SalarySettings, """ sub = timedelta(1 if prev_day else 0) return (datetime.combine(date - sub, wage.night_start), - datetime.combine(date + timedelta(1) - sub, wage.night_end)) + datetime.combine(date + timedelta(days=1) - sub, wage.night_end)) def is_night_shift(start: datetime, end: datetime, @@ -120,7 +120,7 @@ def get_relevant_holiday_times( date = end.date() try: return (datetime.combine(date, time(0)), - datetime.combine(date + timedelta(1), + datetime.combine(date + timedelta(days=1), time(0))) except NameError: return datetime.min, datetime.min @@ -358,11 +358,11 @@ def get_relevant_weeks(year: int, """ month_start, month_end = get_month_times(year, month) week_start = month_start - timedelta(month_start.weekday() + 1) - week_end = week_start + timedelta(7) + week_end = week_start + timedelta(days=7) while week_end <= month_end: yield week_start, week_end week_start = week_end - week_end += timedelta(7) + week_end += timedelta(days=7) def get_monthly_overtime( @@ -487,25 +487,6 @@ def get_settings(session: Session, user_id: int, return settings -def get_time_from_string(string: str) -> time: - """Converts time string to a time object. - - Args: - string (str): Time string. - - Returns: - datetime.time: Time object. - - raises: - ValueError: If string is not of format %H:%M:%S' or '%H:%M', - or if string is an invalid time. - """ - try: - return datetime.strptime(string, config.HOUR_FORMAT).time() - except ValueError: - return datetime.strptime(string, config.ALT_HOUR_FORMAT).time() - - def update_settings(session: Session, wage: SalarySettings, form: Dict[str, str]) -> bool: """Update salary settings instance according to info in `form`. diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 00000000..87644210 --- /dev/null +++ b/app/routers/settings.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Request + +from app.dependencies import templates + + +router = APIRouter( + prefix="/settings", + tags=["settings"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +async def settings(request: Request) -> templates.TemplateResponse: + return templates.TemplateResponse( + "settings.html", + { + "request": request, + }, + ) diff --git a/app/routers/share.py b/app/routers/share.py index a33f44fd..d916915c 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -2,25 +2,24 @@ from sqlalchemy.orm import Session -from app.database.models import Event, Invitation, UserEvent -from app.internal.utils import save +from app.database.models import Event, Invitation from app.internal.export import get_icalendar -from app.routers.user import does_user_exist, get_users +from app.internal.user import user def sort_emails( - participants: List[str], - session: Session, + participants: List[str], + session: Session, ) -> Dict[str, List[str]]: """Sorts emails to registered and unregistered users.""" - emails = {'registered': [], 'unregistered': []} # type: ignore + emails = {"registered": [], "unregistered": []} # type: ignore for participant in participants: - if does_user_exist(email=participant, session=session): - temp: list = emails['registered'] + if user.does_user_exist(email=participant, session=session): + temp: list = emails["registered"] else: - temp: list = emails['unregistered'] + temp: list = emails["unregistered"] temp.append(participant) @@ -28,29 +27,28 @@ def sort_emails( def send_email_invitation( - participants: List[str], - event: Event, + participants: List[str], + event: Event, ) -> bool: """Sends an email with an invitation.""" - - ical_invitation = get_icalendar(event, participants) # noqa: F841 - for _ in participants: - # TODO: send email - pass + if participants: + ical_invitation = get_icalendar(event, participants) # noqa: F841 + for _ in participants: + # TODO: send email + pass return True def send_in_app_invitation( - participants: List[str], - event: Event, - session: Session + participants: List[str], + event: Event, + session: Session, ) -> bool: """Sends an in-app invitation for registered users.""" for participant in participants: # email is unique - recipient = get_users(email=participant, session=session)[0] - + recipient = user.get_users(email=participant, session=session)[0] if recipient.id != event.owner.id: session.add(Invitation(recipient=recipient, event=event)) @@ -62,26 +60,13 @@ def send_in_app_invitation( return True -def accept(invitation: Invitation, session: Session) -> None: - """Accepts an invitation by creating an - UserEvent association that represents - participantship at the event.""" - - association = UserEvent( - user_id=invitation.recipient.id, - event_id=invitation.event.id - ) - invitation.status = 'accepted' - save(session, invitation) - save(session, association) - - def share(event: Event, participants: List[str], session: Session) -> bool: """Sends invitations to all event participants.""" - registered, unregistered = ( - sort_emails(participants, session=session).values() - ) + registered, unregistered = sort_emails( + participants, + session=session, + ).values() if send_email_invitation(unregistered, event): if send_in_app_invitation(registered, event, session): return True diff --git a/app/routers/todo_list.py b/app/routers/todo_list.py new file mode 100644 index 00000000..eb9b3468 --- /dev/null +++ b/app/routers/todo_list.py @@ -0,0 +1,143 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, Form, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse, RedirectResponse +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.config import templates +from app.dependencies import get_db +from app.internal.todo_list import by_id, create_task +from app.internal.utils import get_current_user + +router = APIRouter( + prefix="/task", + tags=["task"], + responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}}, +) + + +@router.post("/delete") +def delete_task( + request: Request, + task_id: int = Form(...), + db: Session = Depends(get_db), +) -> RedirectResponse: + user = get_current_user(db) + task = by_id(db, task_id) + if task.owner_id != user.id: + return templates.TemplateResponse( + "calendar_day_view.html", + {"task_id": task_id}, + status_code=status.HTTP_403_FORBIDDEN, + ) + + date_str = task.date.strftime('%Y-%m-%d') + try: + # Delete task + db.delete(task) + + db.commit() + + except (SQLAlchemyError, TypeError): + return templates.TemplateResponse( + "calendar_day_view.html", + {"task_id": task_id}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return RedirectResponse( + request.url_for("dayview", date=date_str), + status_code=status.HTTP_302_FOUND, + ) + + +@router.post("/add") +async def add_task( + request: Request, + title: str = Form(...), + description: str = Form(...), + date_str: str = Form(...), + time_str: str = Form(...), + is_important: bool = Form(False), + session: Session = Depends(get_db), +) -> RedirectResponse: + user = get_current_user(session) + create_task( + session, + title, + description, + datetime.strptime(date_str, '%Y-%m-%d').date(), + datetime.strptime(time_str, '%H:%M').time(), + user.id, + is_important, + ) + return RedirectResponse( + request.url_for("dayview", date=date_str), + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.post("/edit") +async def edit_task( + request: Request, + task_id: int = Form(...), + title: str = Form(...), + description: str = Form(...), + date_str: str = Form(...), + time_str: str = Form(...), + is_important: bool = Form(False), + session: Session = Depends(get_db), +) -> RedirectResponse: + task = by_id(session, task_id) + task.title = title + task.description = description + task.date = datetime.strptime(date_str, '%Y-%m-%d').date() + task.time = datetime.strptime(time_str, '%H:%M:%S').time() + task.is_important = is_important + session.commit() + return RedirectResponse( + request.url_for("dayview", date=date_str), + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.post("/done/{task_id}") +async def set_task_done( + request: Request, + task_id: int, + session: Session = Depends(get_db), +) -> RedirectResponse: + task = by_id(session, task_id) + task.is_done = True + session.commit() + return RedirectResponse( + request.url_for("dayview", date=task.date.strftime('%Y-%m-%d')), + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.post("/undone/{task_id}") +async def set_task_undone( + request: Request, + task_id: int, + session: Session = Depends(get_db), +) -> RedirectResponse: + task = by_id(session, task_id) + task.is_done = False + session.commit() + return RedirectResponse( + request.url_for("dayview", date=task.date.strftime('%Y-%m-%d')), + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.get("/{task_id}") +async def get_task( + task_id: int, + session: Session = Depends(get_db), +) -> JSONResponse: + task = by_id(session, task_id) + data = jsonable_encoder(task) + return JSONResponse(content=data) diff --git a/app/routers/user.py b/app/routers/user.py index 05206c8f..1e69f7a6 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -1,106 +1,38 @@ -from typing import List - -from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel, Field -from sqlalchemy.exc import SQLAlchemyError +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from starlette.responses import RedirectResponse from starlette.status import HTTP_200_OK -from app.database.models import Event, User, UserEvent +from app.database.models import User from app.dependencies import get_db from app.internal.user.availability import disable, enable -from app.internal.utils import get_current_user, save - +from app.internal.utils import get_current_user router = APIRouter( - prefix="/user", - tags=["user"], + prefix="/users", + tags=["users"], responses={404: {"description": "Not found"}}, ) -class UserModel(BaseModel): - username: str - password: str - email: str = Field(regex='^\\S+@\\S+\\.\\S+$') - language: str - language_id: int - - -@router.get("/list") -async def get_all_users(session=Depends(get_db)): - return session.query(User).all() - - -@router.get("/") +@router.get("/{id}", status_code=status.HTTP_200_OK) async def get_user(id: int, session=Depends(get_db)): - return session.query(User).filter_by(id=id).first() - - -@router.post("/") -def manually_create_user(user: UserModel, session=Depends(get_db)): - create_user(**user.dict(), session=session) - return f'User {user.username} successfully created' - - -def create_user(username: str, - password: str, - email: str, - language_id: int, - session: Session) -> User: - """Creates and saves a new user.""" - - user = User( - username=username, - password=password, - email=email, - language_id=language_id - ) - save(session, user) + user = session.query(User).filter_by(id=id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {id} not found", + ) return user -def get_users(session: Session, **param): - """Returns all users filtered by param.""" - - try: - users = list(session.query(User).filter_by(**param)) - except SQLAlchemyError: - return [] - else: - return users - - -def does_user_exist( - session: Session, - *, user_id=None, - username=None, email=None -): - """Returns True if user exists, False otherwise. - function can receive one of the there parameters""" - - if user_id: - return len(get_users(session=session, id=user_id)) == 1 - if username: - return len(get_users(session=session, username=username)) == 1 - if email: - return len(get_users(session=session, email=email)) == 1 - return False - - -def get_all_user_events(session: Session, user_id: int) -> List[Event]: - """Returns all events that the user participants in.""" - - return ( - session.query(Event).join(UserEvent) - .filter(UserEvent.user_id == user_id).all() - ) +@router.get("/") +async def get_all_users(session=Depends(get_db)): + return session.query(User).all() @router.post("/disable") -def disable_logged_user( - request: Request, session: Session = Depends(get_db)): +def disable_logged_user(request: Request, session: Session = Depends(get_db)): """route that sends request to disable the user. after successful disable it will be directed to main page. if the disable fails user will stay at settings page @@ -113,8 +45,7 @@ def disable_logged_user( @router.post("/enable") -def enable_logged_user( - request: Request, session: Session = Depends(get_db)): +def enable_logged_user(request: Request, session: Session = Depends(get_db)): """router that sends a request to enable the user. if enable successful it will be directed to main page. if it fails user will stay at settings page diff --git a/app/routers/weekview.py b/app/routers/weekview.py index efac161a..1c683632 100644 --- a/app/routers/weekview.py +++ b/app/routers/weekview.py @@ -7,55 +7,86 @@ from sqlalchemy.orm.session import Session from app.database.models import Event, User -from app.dependencies import get_db, TEMPLATES_PATH +from app.dependencies import TEMPLATES_PATH, get_db +from app.internal.security.dependencies import current_user from app.routers.dayview import ( - DivAttributes, dayview, get_events_and_attributes + CurrentTimeAttributes, + EventsAttributes, + dayview, + get_all_day_events, + get_events_and_attributes, ) - templates = Jinja2Templates(directory=TEMPLATES_PATH) - router = APIRouter() class DayEventsAndAttrs(NamedTuple): day: datetime template: Jinja2Templates.TemplateResponse - events_and_attrs: Tuple[Event, DivAttributes] + events_and_attrs: Tuple[Event, EventsAttributes] + current_time_and_attrs: CurrentTimeAttributes + all_day_events: Event -def get_week_dates(firstday: datetime) -> Iterator[datetime]: +def get_week_dates(first_day: datetime) -> Iterator[datetime]: rest_of_days = [timedelta(days=1) for _ in range(6)] - rest_of_days.insert(0, firstday) + rest_of_days.insert(0, first_day) return accumulate(rest_of_days) async def get_day_events_and_attributes( - request: Request, day: datetime, session: Session, user: User, - ) -> DayEventsAndAttrs: + request: Request, + day: datetime, + session: Session, + user: User, +) -> DayEventsAndAttrs: template = await dayview( request=request, - date=day.strftime('%Y-%m-%d'), - view='week', - session=session + date=day.strftime("%Y-%m-%d"), + view="week", + session=session, + user=user, ) events_and_attrs = get_events_and_attributes( - day=day, session=session, user_id=user.id) - return DayEventsAndAttrs(day, template, events_and_attrs) + day=day, + session=session, + user_id=user.user_id, + ) + current_time_and_attrs = CurrentTimeAttributes(date=day) + all_day_events = get_all_day_events( + day=day, + session=session, + user_id=user.user_id, + ) + return DayEventsAndAttrs( + day, + template, + events_and_attrs, + current_time_and_attrs, + all_day_events, + ) -@router.get('/week/{firstday}') +@router.get("/week/{first_day}") async def weekview( - request: Request, firstday: str, session=Depends(get_db) - ): - user = session.query(User).filter_by(username='test_username').first() - firstday = datetime.strptime(firstday, '%Y-%m-%d') - week_days = get_week_dates(firstday) - week = [await get_day_events_and_attributes( - request, day, session, user - ) for day in week_days] - return templates.TemplateResponse("weekview.html", { - "request": request, - "week": week, - }) + request: Request, + first_day: str, + session=Depends(get_db), + user: User = Depends(current_user), +): + first_day = datetime.strptime(first_day, "%Y-%m-%d") + week_days = get_week_dates(first_day) + week = [ + await get_day_events_and_attributes(request, day, session, user) + for day in week_days + ] + return templates.TemplateResponse( + "weekview.html", + { + "request": request, + "week": week, + "view": "week", + }, + ) diff --git a/app/routers/weight.py b/app/routers/weight.py new file mode 100644 index 00000000..9f35a5dc --- /dev/null +++ b/app/routers/weight.py @@ -0,0 +1,71 @@ +from typing import Union + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse + +from app.database.models import User +from app.dependencies import get_db, templates + +router = APIRouter( + tags=["weight"], +) + + +@router.get("/weight") +async def get_weight( + request: Request, + session: Session = Depends(get_db), + target: Union[float, None] = None, + current_weight: Union[float, None] = None, +): + + # TODO Waiting for user registration + user_id = 1 + user = session.query(User).filter_by(id=user_id).first() + target = user.target_weight + if current_weight: + return RedirectResponse(url="/") + return templates.TemplateResponse( + "weight.html", + { + "request": request, + "target": target, + "current_weight": current_weight, + }, + ) + + +@router.post("/weight") +async def weight(request: Request, session: Session = Depends(get_db)): + user_id = 1 + user = session.query(User).filter_by(id=user_id).first() + data = await request.form() + target = data["target"] + current_weight = data["current_weight"] + if target: + user.target_weight = target + session.commit() + else: + target = user.target_weight + if not target: + target = current_weight + way_to_go = float(current_weight) - float(target) + way_to_go = round(way_to_go, 2) + if way_to_go > 0: + way_message = f"Weight to lose: {way_to_go} Kg" + elif way_to_go < 0: + way_to_go = abs(way_to_go) + way_message = f"Weight to add: {way_to_go} Kg" + else: + way_message = f"Great! You have reached your goal: {target} Kg" + + return templates.TemplateResponse( + "weight.html", + { + "request": request, + "target": target, + "current_weight": current_weight, + "way_message": way_message, + }, + ) diff --git a/app/routers/whatsapp.py b/app/routers/whatsapp.py index cbd1e254..1d7a625c 100644 --- a/app/routers/whatsapp.py +++ b/app/routers/whatsapp.py @@ -1,7 +1,7 @@ from typing import Optional +from urllib.parse import urlencode from fastapi import APIRouter -from urllib.parse import urlencode router = APIRouter(tags=["utils"]) @@ -19,7 +19,7 @@ def make_link(phone_number: Optional[str], message: Optional[str]) -> str: Returns: A WhatsApp message URL. """ - api = 'https://api.whatsapp.com/send?' - url_query = {'phone': phone_number, 'text': message} + api = "https://api.whatsapp.com/send?" + url_query = {"phone": phone_number, "text": message} link = api + urlencode(url_query) return link diff --git a/app/static/agenda_style.css b/app/static/agenda_style.css index fb357f7f..950cb546 100644 --- a/app/static/agenda_style.css +++ b/app/static/agenda_style.css @@ -6,6 +6,23 @@ text-align: center; } +.agenda_filter_grid { + grid-area: header; +} + +.agenda_grid { + display: grid; + grid-template-areas: + "header filter" + "sidebar sidebar"; + grid-template-columns: 5fr 2fr; + grid-gap: 0 1.25em; +} + +.category_filter { + grid-area: filter; +} + .event_line { width: 80%; margin-left: 2em; @@ -18,6 +35,22 @@ grid-gap: 0.6em; } +.event_line[data-value="hidden"], +.wrapper[data-value="hidden"] +{ + display: none; +} + +.event_line[data-value="visible"] +{ + display: grid; +} + +.wrapper[data-value="visible"] +{ + display: block; +} + .duration { font-size: small; } diff --git a/app/static/audio_settings.js b/app/static/audio_settings.js new file mode 100644 index 00000000..1e8f49cb --- /dev/null +++ b/app/static/audio_settings.js @@ -0,0 +1,174 @@ +// Event listeners +const set_checked_ids = [ + "music-on", + "music-off", + "sound-effects-on", + "sound-effects-off", +]; +const other_ids_and_their_funcs = [ + ["activate", set_default], + ["on", start_audio], + ["off", stop_audio], +]; + +window.addEventListener("load", function () { + set_checked_ids.forEach((val) => { + add_set_checked_listener(val); + }); + other_ids_and_their_funcs.forEach((val) => { + perform_func_on_click(val[0], val[1]); + }); +}); + +/** + * @summary This function gets an element_id and adds eventListener to it. + * upon activation the set_checked function runs, + * with the element_id as argument. + * @param {string} element_id - the id attribute of the html element. One of: Music On/off, Sound Effects On/Off. + */ +function add_set_checked_listener(element_id) { + const elem = document.getElementById(element_id); + if (elem) { + elem.addEventListener("click", function () { + set_checked(element_id); + }); + } +} + +/** + * @summary This function gets an element_id and a function and + * adds eventListener to the element with element_id. + * upon activation the function supplied runs with no arguments. + * @param {function} func - the function to run. + * @param {string} element_id - the id attribute of the html element. + * One of: Music On/off, Sound Effects On/Off. + */ +function perform_func_on_click(element_id, func) { + const elem = document.getElementById(element_id); + if (elem) { + elem.addEventListener("click", func); + } +} + +/** + * @summary This function gets an element_id and set its checked attribute. + * According to the element_id, we disable or enable track selection and volume change for that audio type. + * @param {string} element_id - the id attribute of the html element. One of: Music On/off, Sound Effects On/Off. + */ +function set_checked(element_id) { + const is_music = element_id.includes("music"); + const is_off = element_id.includes("off"); + const to_toggle = is_music ? "music" : "sfx"; + set_disabled_or_enabled(to_toggle, is_music, is_off); +} + +/** + * @summary This function sets audio options off by default. + */ +function set_default() { + set_default_for_audio_type("music-on", "music-off"); + set_default_for_audio_type("sound-effects-on", "sound-effects-off"); +} + +/** + * @summary This function gets class or id name, boolean value to tell if class or id, and bolean value to set, + * And sets the disabled attribute of the corresponding element accordingly. + * @param {string} name - name of the elements' class or id. + * @param {Boolean} is_class - class if true, id otherwise. + * @param {Boolean} to_set - we set the disabled attribute if true, false oterwise. + */ +function set_disabled_or_enabled(name, is_class, to_set) { + if (is_class) { + let elements = document.getElementsByClassName(name); + for (let element of elements) { + element.disabled = to_set; + } + } else { + document.getElementById(name).disabled = to_set; + } + document.getElementById("rangeInput-" + name).disabled = to_set; +} + +/** + * @summary This function is an helper function for the set_default function, + * and we use it to privent code duplication by having one function to handle music as well as sound effects. + * @param {string} audio_id_on - the id corresponding to the On option of the element, for Music as well as sfx. + * @param {string} audio_id_off - the id corresponding to the Off option of the element, for Music as well as sfx. + */ +function set_default_for_audio_type(audio_id_on, audio_id_off) { + const is_on = document.getElementById(audio_id_on).checked; + const is_off = document.getElementById(audio_id_off).checked; + if (!is_on && !is_off) { + document.getElementById(audio_id_off).checked = true; + } +} + +function prepare_audio() { + let audio_settings = JSON.parse(this.response); + const music = document.getElementById("my-audio"); + const sfx = document.getElementById("sfx"); + audio_settings = JSON.parse(audio_settings); + const music_on = audio_settings["music_on"]; + + if (music.muted && (music_on || music_on == null)) { + const choices = audio_settings["playlist"]; + music.src = `/static/tracks/${ + choices[Math.floor(Math.random() * choices.length)] + }`; + music.volume = audio_settings["music_vol"]; + music.muted = false; + } + + if (music.paused) { + music.play(); + } + + const sfx_on = audio_settings["sfx_on"]; + if (sfx.muted && (sfx_on || sfx_on == null)) { + const sfx_choice = audio_settings["sfx_choice"]; + sfx.src = "/static/tracks/" + sfx_choice; + sfx.volume = audio_settings["sfx_vol"]; + sfx.muted = false; + } + + if (!sfx.muted) { + document.body.addEventListener("click", play_sfx, true); + } +} + +/** + * @summary This function loads user choices and starts audio. + */ +function start_audio() { + const request = new XMLHttpRequest(); + request.open("GET", "/audio/start", true); + + request.onload = prepare_audio; + request.send(); +} + +/** + * @summary This function plays a sound effect. + */ +function play_sfx() { + const sfx = document.getElementById("sfx"); + sfx.play(); +} + +/** + * @summary This function stops the audio. + */ +function stop_audio() { + const music = document.getElementById("my-audio"); + const sfx = document.getElementById("sfx"); + + if (!music.paused) { + music.pause(); + music.currentTime = 0; + } + + if (!sfx.muted) { + sfx.muted = true; + document.body.removeEventListener("click", play_sfx, false); + } +} diff --git a/app/static/categories_style.css b/app/static/categories_style.css new file mode 100644 index 00000000..70afe5d7 --- /dev/null +++ b/app/static/categories_style.css @@ -0,0 +1,32 @@ + + +body { + color: #333; + margin: auto; + font: 1.2em / 1.2 Arial, Helvetica, sans-serif; +} + +h1 { + color: black; + font-size: 150%; + text-align: center; +} + +form { + text-align: center; +} + +input { + border-radius: 0.5em; + color: plum; + padding: 0.5em; +} + +input[type="color"].custom { + padding: 0; + border: none; + height: 1.875em; + width: 9.375em; + vertical-align: middle; + border-radius: 0.5em; +} \ No newline at end of file diff --git a/app/static/credits_pictures/profile.png b/app/static/credits_pictures/profile.PNG similarity index 100% rename from app/static/credits_pictures/profile.png rename to app/static/credits_pictures/profile.PNG diff --git a/app/static/credits_style.css b/app/static/credits_style.css index 3234e564..ba371c4f 100644 --- a/app/static/credits_style.css +++ b/app/static/credits_style.css @@ -1,6 +1,5 @@ body { - margin-left: 6.25em; - margin-right: 6.25em; + background-color: var(--backgroundcol); } div.gallery { diff --git a/app/static/dayview.css b/app/static/dayview.css index 4b13cbb6..3cde2aa8 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -4,12 +4,11 @@ body { flex-direction: column; overflow: hidden; } -#day-view { +.day-view-class { display: flex; flex: 1; flex-direction: column; position: relative; - overflow-y: hidden; } #top-tab { @@ -24,6 +23,8 @@ body { margin-bottom: var(--space_xs); line-height: 1; overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; } .schedule::-webkit-scrollbar { @@ -46,7 +47,20 @@ body { grid-row: 1 / -1; grid-column: 1 / -1; display: grid; - grid-template-rows: repeat(100, 1fr); + grid-template-rows: repeat(100, auto); +} + +.timegrid { + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, auto); + z-index: 43; +} + +.sub-timegrid { + display: grid; + grid-template-rows: repeat(15, auto); } .hour-block { @@ -153,6 +167,8 @@ body { position: absolute; right: 0.5rem; bottom: 0.5rem; + margin-bottom: 1.5em; + z-index: 60; } .plus_image { @@ -164,3 +180,110 @@ body { width: 1.2rem; height: 1.2rem; } + +#current_time_cursor { + border-bottom: 2.5px dotted rgba(255, 0, 0, 0.808); +} + +#all-day-events { + background-color: var(--primary); + word-spacing: 0.25em; +} + + +.todo-style { + background: #F7F7F7; +} + +#table { + max-width: 31.25em; +} + +.open-button { + background-color: var(--primary); + max-width: 10%; + color: #fff; + margin-top: 0.75em; + margin-left: 45.5%; + font-size: 1.25em; + border-radius: 20px; + transition-duration: 0.4s; + box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2), 0 6px 20px 0 rgba(0,0,0,0.19); +} + +.open-button:hover { + background-color: #ccddff; + color: #00001a; + box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); +} + +.top-todo { + background: #2B346A; +} + +.todo-style h1 { + color: #fff; + text-align: center; +} + +.important { + border-bottom: 5px solid blue; + margin-left: 3em; +} + +.todo-style input[type="text"], +.todo-style input[type="date"], +.todo-style input[type="time"], +.todo-style textarea { + -webkit-transition: all 0.30s ease-in-out; + -moz-transition: all 0.30s ease-in-out; + -ms-transition: all 0.30s ease-in-out; + -o-transition: all 0.30s ease-in-out; + outline: none; + box-sizing: border-box; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + width: 100%; + background: #fff; + margin-bottom: 4%; + border: 1px solid #ccc; + padding: 3%; + color: #555; +} + +.todo-style input[type="text"]:focus, +.todo-style input[type="date"]:focus, +.todo-style input[type="time"]:focus, +.todo-style textarea:focus { + box-shadow: 0 0 5px #43D1AF; + padding: 3%; + border: 1px solid #43D1AF; +} + +.todo-style th, .todo-style td { + padding-right: 3em; + padding-top: 2em; + text-align: left; +} + +.todo-style button { + border-radius: 20px; + padding: 2%; + text-align: right; + background-color: #80bfff; +} + + +.edit { + text-decoration: none; + background-color: #eafaf6; + border-style: solid; + border-color: #004d99; + border-radius: 10px; +} + +.todo-style button:hover { + background-color: var(--primary); + color: white; + box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); +} \ No newline at end of file diff --git a/app/static/event/check_country_time.js b/app/static/event/check_country_time.js new file mode 100644 index 00000000..27605579 --- /dev/null +++ b/app/static/event/check_country_time.js @@ -0,0 +1,84 @@ +function checkCountryTime() { + const ERROR_TIME_DURATION = 3000; + const TIME_NOT_FILLED = 'Please enter the meeting date and time'; + const COUNTRY_NOT_FILLED = 'Please choose country'; + const modal_container = document.querySelector('.countries-modal-container'); + const open_modal = document.querySelector('.open-countries-modal'); + const close_modal = document.querySelector('.close-countries-modal'); + const submit_country = document.querySelector('.check-time'); + const upper_result = document.querySelector('.upper-result'); + const start_result = document.querySelector('.start-result'); + const end_result = document.querySelector('.end-result'); + const error_msg = document.querySelector('.empty-fields-error'); + const user_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + function getStartDate() { + const start_date = document.getElementById('start_date').value; + return start_date; + } + + function getStartTime() { + const start_time = document.getElementById('start_time').value; + return start_time; + } + + function getEndDate() { + const end_date = document.getElementById('end_date').value; + return end_date; + } + + function getEndTime() { + const end_time = document.getElementById('end_time').value; + return end_time; + } + + function displayErrorMsg(content) { + error_msg.classList.remove('empty-fields-error-disappear'); + error_msg.innerText = content; + setTimeout(() => error_msg.classList.add('empty-fields-error-disappear'), ERROR_TIME_DURATION); + } + + function convertTimes(data, chosen_country) { + const start_datetime = new Date(getStartDate() + 'T' + getStartTime()); + let converted_start_time = start_datetime.toLocaleTimeString('en-US', {timeZone: data.timezone}); + upper_result.innerText = 'Meeting Time in ' + chosen_country + ' is:'; + start_result.innerText = converted_start_time; + if (!(getEndDate() === "" || getEndTime() === "")) { + const end_datetime = new Date(getEndDate() + 'T' + getEndTime()); + let converted_end_time = end_datetime.toLocaleTimeString('en-US', {timeZone: data.timezone}); + end_result.innerText = ' until ' + converted_end_time; + } + } + + open_modal.addEventListener('click', (event) => { + event.preventDefault(); + modal_container.classList.add('modal-active'); + if (getStartDate() === '' || getStartTime() === '') { + displayErrorMsg(TIME_NOT_FILLED); + } + }); + + submit_country.addEventListener('click', (event) => { + event.preventDefault(); + if (getStartDate() === '' || getStartTime() === '') { + displayErrorMsg(TIME_NOT_FILLED); + return; + } + const chosen_country = document.getElementById('countries-datalist').value; + if (chosen_country === '') { + displayErrorMsg(COUNTRY_NOT_FILLED); + return; + } + fetch(`/event/timezone/country/${chosen_country}`) + .then(response => response.json()) + .then(data => convertTimes(data, chosen_country)) + }); + + close_modal.addEventListener('click', (event) => { + event.preventDefault(); + modal_container.classList.remove('modal-active'); + }); +} + + +document.addEventListener("DOMContentLoaded", checkCountryTime); diff --git a/app/static/event/eventedit.css b/app/static/event/eventedit.css index a8a0e92e..daca1757 100644 --- a/app/static/event/eventedit.css +++ b/app/static/event/eventedit.css @@ -29,12 +29,14 @@ form { .form_row, .form_row_start, -.form_row_end { +.form_row_end, +.user_categories { display: flex } .form_row_start, -.form_row_end { +.form_row_end, +.user_categories { flex: 1; } @@ -62,4 +64,109 @@ textarea, input[type="submit"] { width: 100%; -} \ No newline at end of file +} + +.countries-modal-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background-color: rgba(0 , 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0, 5s; + font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; +} + +.modal-active { + visibility: visible; + opacity: 1; +} + +.countries-modal { + position: relative; + background-color: whitesmoke; + width: fit-content; + height: fit-content; + display: flex; + flex-direction: column; + justify-content: space-around; + padding: 2em 4em; + border-radius: 0.3em; + box-shadow: 0 0.2em 0.4em rgba(0, 0, 0, 0.2); + text-align: center; + transition: opacity 0.3s ease; + z-index: 100; +} + +.check-time { + padding: 0.7em 1em; + background-color: rgba(73, 65, 65, 0.842); + color: white; + border: none; + border-radius: 0.3em; + cursor: pointer; +} + +.check-time:hover { + background-color: rgba(112, 108, 108, 0.842); + border: none; +} + +.close-countries-modal { + position: absolute; + color: rgba(73, 65, 65, 0.842); + top: 1em; + right: 1em; + font-weight: bold; +} + +.close-countries-modal:hover { + color: brown; + cursor: pointer; +} + +#countries-datalist { + margin: 1em 0; + padding: 0.7em 1em; + border: 0.1em solid rgba(73, 65, 65, 0.842); + border-radius: 0.3em; +} + +#countries-datalist:hover { + background-color:aliceblue; +} + +.empty-fields-error { + color: brown; +} + +.empty-fields-error-disappear { + display: none; +} + +.results { + color: rgb(9, 65, 65); + margin-bottom: 1em; + font-weight: bold; +} + +.icon-credits, +.icon-credits a:link, +.icon-credits a:visited, +.icon-credits a:hover, +.icon-credits a:active { + color: rgb(202, 202, 202); +} + +.shared-list-item-off { + display: none; +} + +.shared-list-item-on { + display: flex; +} diff --git a/app/static/event/eventview.css b/app/static/event/eventview.css index 3a420e0e..f3900a04 100644 --- a/app/static/event/eventview.css +++ b/app/static/event/eventview.css @@ -12,13 +12,13 @@ body { flex-direction: column; } -.event_view_wrapper { +.event-view-wrapper { display: flex; flex-direction: column; height: 100%; } -#event_view_tabs { +#event-view-tabs { flex: 1; } @@ -28,32 +28,28 @@ body { flex-direction: column; } -.event_info_row, -.event_info_row_start, -.event_info_row_end { - display: flex +.event-info-row, +.event-info-row-start, +.event-info-row-end { + display: flex; } -.event_info_row_start, -.event_info_row_end { +.event-info-row-start, +.event-info-row-end { flex: 1; } -.event_info_row_end { +.event-info-row-end { justify-content: flex-end; } -div.event_info_row, -.event_info_buttons_row { +div.event-info-row, +.event-info-buttons-row { align-items: center; margin-block-start: 0.2em; margin-block-end: 0.2em; } -.title { - border-bottom: 4px solid blue; -} - .title h1 { white-space: nowrap; margin-block-start: 0.2em; @@ -65,11 +61,15 @@ div.event_info_row, padding-right: 1em; } -.event_info_buttons_row { +.event-info-buttons-row { min-height: 2.25em; max-height: 3.25em; } button { height: 100%; -} \ No newline at end of file +} + +.google-maps-object { + width: 100%; +} diff --git a/app/static/global.css b/app/static/global.css index c7e2deb4..96647c6e 100644 --- a/app/static/global.css +++ b/app/static/global.css @@ -49,14 +49,14 @@ body { } body { - background-color: #F7F7F7; - color: #222831; - font-family: "Assistant", "Ariel", sans-serif; - font-weight: 400; - line-height: 1.7; - text-rendering: optimizeLegibility; - scroll-behavior: smooth; - width: 100%; + background-color: var(--backgroundcol); + color: var(--textcolor); + font-family: "Assistant", "Ariel", sans-serif; + font-weight: 400; + line-height: 1.7; + text-rendering: optimizeLegibility; + scroll-behavior: smooth; + width: 100%; } a { diff --git a/app/static/grid_style.css b/app/static/grid_style.css index d824c1b0..026474a0 100644 --- a/app/static/grid_style.css +++ b/app/static/grid_style.css @@ -1,3 +1,22 @@ +:root[data-color-mode="regular"] { + --backgroundcol: #F7F7F7; + --textcolor: #222831; + --start-of-month: #E9ECEf; + --primary-variant: #FFDE4D; + --secondary: #EF5454; + --borders: #E7E7E7; + --borders-variant: #F7F7F7; +} + +:root[data-color-mode="dark"] { + --backgroundcol: #000000; + --textcolor: #EEEEEE; + --start-of-month: #8C28BF; + --secondary: #EF5454; + --borders: #E7E7E7; + --borders-variant: #F7F7F7; +} + * { margin: 0; padding: 0; @@ -24,7 +43,7 @@ nav { position: sticky; display: flex; flex-direction: column; - top:var(--space_s); + top: var(--space_s); } .fixed-features, @@ -37,6 +56,7 @@ nav { flex: 1; display: flex; flex-direction: column; + background: var(--backgroundcol); } .user-features { @@ -84,19 +104,21 @@ nav { } .settings-open { - width: 20rem; + width: 20rem; } -img {fill: var(--background);} +img { + fill: var(--background); +} header { z-index: 5; position: sticky; top: 0; display: flex; - grid-flow: row wrap; margin: 0 var(--space_s); - background-color: var(--background); + margin: 0 1rem 0 1rem; + background-color: var(--backgroundcol); } header div { @@ -107,6 +129,13 @@ header div { text-align: end; } +.logo-container { + max-width: 5em; + height: auto; + margin-left: auto; + margin-top: 0.5em; +} + /* Main Element Grid */ main { display: flex; @@ -135,7 +164,8 @@ main { display: grid; grid-template-columns: repeat(7, 1fr); margin: var(--space_s) var(--space_s) 0 var(--space_s); - background-color: var(--background); + margin: 1rem 1rem 0 1rem; + background-color: var(--backgroundcol); align-self: stretch; } @@ -192,9 +222,13 @@ main { font-weight: 400; } -.day:hover {border: 0.1rem solid var(--primary);} +.day:hover { + border: 0.1rem solid var(--primary); +} -.day:hover .day-number{color: var(--negative);} +.day:hover .day-number { + color: var(--negative); +} .day:hover .add-small { display: block; @@ -288,7 +322,7 @@ main { height: 1.5rem; } -.month-event div{ +.month-event div { height: 1.5rem; width: 100%; transition: all 0.3s ease; @@ -334,31 +368,59 @@ main { } /* Text Colors */ -.text-yellow {color: var(--secondary);} +.text-yellow { + color: var(--secondary); +} -.text-gray {color: var(--on-surface);} +.text-gray { + color: var(--on-surface); +} -.text-lightgray {color: var(--background);} +.text-lightgray { + color: var(--background); +} -.text-darkblue {color: var(--primary);} +.text-darkblue { + color: var(--primary); +} /* Borders */ -.border-dash-darkblue {border: 0.125rem dashed var(--primary);} +.border-dash-darkblue { + border: 0.125rem dashed var(--primary); +} -.border-darkblue {border: 0.125rem solid var(--primary);} +.border-darkblue { + border: 0.125rem solid var(--primary); +} -.underline-yellow {border-bottom: 0.25rem solid var(--secondary);} +.underline-yellow { + border-bottom: 0.25rem solid var(--secondary); +} /* Background Color */ -.background-darkblue {background-color: var(--primary-variant);} +.background-darkblue { + background-color: var(--primary-variant); +} -.background-red {background-color: var(--negative);} +.background-red { + background-color: var(--negative); +} -.background-lightgray {background-color: var(--surface);} +.background-yellow { + background-color: var(--secondary); +} -.background-yellow {background-color: var(--secondary);} +.background-green { + background-color: var(--positive); +} -.background-green {background-color: var(--positive);} +.background-lightgray { + background-color: var(--start-of-month); +} + +.background-green { + background-color: var(--bold_tertiary); +} /* Buttons */ @@ -371,4 +433,66 @@ main { .button:hover { font-weight: 700; -} \ No newline at end of file +} + +.dates-calc { + background-color: #222831; + color: white; +} + +/*toggle views*/ +.btn-group-sm>.btn, .btn-sm { + padding: .1rem .3rem !important; + font-size: .7rem !important; +} + +.btn-outline-primary { + border-color: var(--primary) !important; + color: var(--primary) !important; +} + +.btn-outline-primary:hover { + background-color: var(--primary) !important; + color: white !important; +} + +.btn-group>.btn-check:checked+.btn { + background-color: var(--primary) !important; + color: white !important; +} + +.btn-group>.btn-check:focus+.btn { + box-shadow: var(--primary) !important; +} + +#calendarview { + -ms-overflow-style: none; + scrollbar-width: none; +} + +#calendarview::-webkit-scrollbar { + display: none; +} + +.day-view-scrollbar { + overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.day-view-scrollbar::-webkit-scrollbar { + display: none; +} + +.day-view-limitions { + max-width: 30vw; + max-height: 90vh +} + +.transition { + transition-duration: 0.4s; +} + +#darkmode { + cursor: pointer; +} diff --git a/app/static/images/calendar.png b/app/static/images/calendar.png new file mode 100644 index 00000000..bde6774c Binary files /dev/null and b/app/static/images/calendar.png differ diff --git a/app/static/images/icons/israel.svg b/app/static/images/icons/israel.svg new file mode 100644 index 00000000..7a355c35 --- /dev/null +++ b/app/static/images/icons/israel.svg @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> +<circle style="fill:#F0F0F0;" cx="256" cy="256" r="256"/> +<g> + <path style="fill:#0052B4;" d="M352.393,200.348H288.13L256,144.696l-32.129,55.652h-64.264L191.741,256l-32.134,55.652h64.264 + L256,367.304l32.13-55.652h64.263L320.259,256L352.393,200.348z M295.475,256l-19.736,34.188h-39.475L216.525,256l19.738-34.188 + h39.475L295.475,256z M256,187.623l7.346,12.724h-14.69L256,187.623z M196.786,221.812h14.692l-7.346,12.724L196.786,221.812z + M196.786,290.188l7.347-12.724l7.346,12.724H196.786z M256,324.376l-7.345-12.724h14.691L256,324.376z M315.214,290.188h-14.692 + l7.347-12.724L315.214,290.188z M300.522,221.812h14.692l-7.346,12.724L300.522,221.812z"/> + <path style="fill:#0052B4;" d="M415.357,55.652H96.643c-23.363,18.608-43.399,41.21-59.069,66.783h436.852 + C458.755,96.863,438.719,74.26,415.357,55.652z"/> + <path style="fill:#0052B4;" d="M96.643,456.348h318.713c23.363-18.608,43.399-41.21,59.069-66.783H37.574 + C53.245,415.137,73.281,437.74,96.643,456.348z"/> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +</svg> diff --git a/app/static/images/icons/waze.svg b/app/static/images/icons/waze.svg new file mode 100644 index 00000000..d2c6330b --- /dev/null +++ b/app/static/images/icons/waze.svg @@ -0,0 +1,172 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + version="1.1" + id="svg2" + xml:space="preserve" + width="908.64001" + height="782.15997" + viewBox="0 0 908.64001 782.15997" + sodipodi:docname="waze.eps"><metadata + id="metadata8"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs + id="defs6"><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath22"><path + d="M 0,0.03125 H 6814.81 V 2581.8513 H 0 Z" + id="path20" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath28"><path + d="M 6814.81,0.03125 H 0 L 0.0078125,2581.78 H 6814.79 V -0.0078125 M 4396.59,1399.07 c -309.74,-1.43 -558.7,-52.67 -558.7,-115.76 0,-63.9 256.11,-115.8 572.01,-115.8 313.72,0 568.4,51.16 572.03,114.44 v 2.73 c -3.57,62.46 -251.15,112.97 -558.73,114.39 h -26.61 m -1910.62,0 c -309.74,-1.43 -558.69,-52.67 -558.69,-115.76 0,-63.9 256.1,-115.8 572,-115.8 314.35,0 569.45,51.36 572.06,114.82 v 1.97 c -2.58,62.63 -250.55,113.35 -558.75,114.77 h -26.62 m 2628.39,902.64 c -5.91,0 -10.05,-4.36 -10.05,-10.05 v -3.48 c 0,-5.89 3.93,-9.38 8.52,-10.5 0.43,-8.5 -3.29,-13.96 -12.23,-17.9 -1.75,-0.65 -2.84,-2.19 -2.84,-3.94 0,-2.61 1.73,-4.36 4.58,-4.36 3.49,0 10.27,3.29 14.86,7.88 5.01,5.01 7.85,13.08 7.85,26.2 v 4.14 c 0,7.23 -4.36,12.01 -10.69,12.01" + id="path26" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath40"><path + d="m 5102.34,2251.48 c -2.85,0 -4.58,1.75 -4.58,4.36 0,1.75 1.09,3.29 2.84,3.94 8.94,3.94 12.66,9.4 12.23,17.9 -4.59,1.12 -8.52,4.61 -8.52,10.5 v 3.48 c 0,5.69 4.14,10.05 10.05,10.05 6.33,0 10.69,-4.78 10.69,-12.01 v -4.14 c 0,-13.12 -2.84,-21.19 -7.85,-26.2 -4.59,-4.59 -11.37,-7.88 -14.86,-7.88" + id="path38" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath52"><path + d="m 4409.9,1167.51 c -315.9,0 -572.01,51.9 -572.01,115.8 0,63.09 248.96,114.33 558.7,115.76 h 26.61 c 307.58,-1.42 555.16,-51.93 558.73,-114.39 v -2.73 c -3.63,-63.28 -258.31,-114.44 -572.03,-114.44" + id="path50" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath64"><path + d="m 2499.28,1167.51 c -315.9,0 -572,51.9 -572,115.8 0,63.09 248.95,114.33 558.69,115.76 h 26.62 c 308.2,-1.42 556.17,-52.14 558.75,-114.77 v -1.97 c -2.61,-63.46 -257.71,-114.82 -572.06,-114.82" + id="path62" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath76"><path + d="m 3544.52,5713.37 c -397.87,-53.75 -789.16,-219.26 -1101.56,-465.9 -347.55,-274.4 -571.38,-625.59 -647.54,-1015.63 -22.96,-117.74 -32.65,-246.48 -42.97,-382.76 -40.1,-531.35 -95.79,-854.08 -529.96,-854.08 -50.57,0 -96.87,-28.54 -119.52,-73.91 -22.57,-45.37 -17.61,-99.51 12.96,-139.99 586.8,-778.62 1593.91,-868.73 2300.39,-868.73 17.99,0 35.91,0.06 53.44,0.22 13.03,0.09 26.52,0.09 40.25,0.09 20.25,0 41.18,0 62.9,-0.16 23.42,0 47.7,-0.15 72.67,-0.15 330.02,0 678.25,16.05 988.95,131.92 551.76,205.76 1016.56,668.16 1184.33,1178.28 189.4,575.63 101.44,1158.26 -247.57,1640.52 -388.26,536.39 -1054.8,869.59 -1739.65,869.59 -95.78,0 -192.34,-6.52 -287.12,-19.31" + id="path74" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath82"><path + d="m 6030.78,5732.55 -22.69,-3849.25 -4950.22,29.19 22.7,3849.25 z" + id="path80" /></clipPath><radialGradient + fx="0" + fy="0" + cx="0" + cy="0" + r="1" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-21.1444,-3585.29,4885.28,-28.8111,4011.89,4972.22)" + spreadMethod="pad" + id="radialGradient92"><stop + style="stop-opacity:1;stop-color:#f8f9fa" + offset="0" + id="stop84" /><stop + style="stop-opacity:1;stop-color:#f8f9fa" + offset="0.0522727" + id="stop86" /><stop + style="stop-opacity:1;stop-color:#cdd9dc" + offset="0.735869" + id="stop88" /><stop + style="stop-opacity:1;stop-color:#f1f2f4" + offset="1" + id="stop90" /></radialGradient></defs><sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="640" + inkscape:window-height="480" + id="namedview4" /><g + id="g10" + inkscape:groupmode="layer" + inkscape:label="ink_ext_XXXXXX" + transform="matrix(1.3333333,0,0,-1.3333333,0,782.16)"><g + id="g12" + transform="scale(0.1)"><path + d="m 5100.6,2259.78 c 8.94,3.94 12.66,9.4 12.23,17.9 -4.59,1.12 -8.52,4.61 -8.52,10.5 v 3.48 c 0,5.69 4.14,10.05 10.05,10.05 6.33,0 10.69,-4.78 10.69,-12.01 v -4.14 c 0,-13.12 -2.84,-21.19 -7.85,-26.2 -4.59,-4.59 -11.37,-7.88 -14.86,-7.88 -2.85,0 -4.58,1.75 -4.58,4.36 0,1.75 1.09,3.29 2.84,3.94" + style="fill:#57849c;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path14" /><g + id="g16"><g + id="g18" + clip-path="url(#clipPath22)"><g + id="g24" + clip-path="url(#clipPath28)"><g + id="g30" + transform="matrix(6831.2,0,0,2598.2,-8.1,-8.09994)"><image + width="1" + height="1" + style="image-rendering:optimizeSpeed" + preserveAspectRatio="none" + transform="matrix(1,0,0,-1,0,1)" + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA48AAAFaCAYAAAC+BWi6AAAABHNCSVQICAgIfAhkiAAAIABJREFUeJzt3ctCI0kWJFAQ/f8/PIWYxWxGdrPd6nZA8jpnp4yXKxQK4SgNe357e3t7AgAAgIPbZw8AAACAr8/kEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgMrkEQAAgOo/nz0AAL6ut7e3zx4CH+z5+fmzhwDAN+GbRwAAACqTRwAAACqTRwAAACqZR2Dlp2XgMu/1054fNL/9mt9mPtv5+uwM6fb1/OzxAt+Lbx4BAACoTB4BAACoTB4BAACont9+e9gBPtjVt9hH51F+2i3g6z2b84j++umXb+K9fbV7yPIav/qO+G3vqPf+THrvzyAZTvhYvnkEAACgMnkEAACgMnkEAACg0vPIr5d5i6+Wl7h/tTzRB/vwDGa8vttXezu6t7d2hL/8+v6y64nv4H3vudu93ctbon0kfPQ968Nz7+sN4jPz4vG/1ifu9NV/RoC/zTePAAAAVCaPAAAAVCaPAAAAVHoe+fX+9lvg6tHqeK/mMcr+t+Nv+ZCPPv/7vbct5F3gZ7n2nv/bd4S/f0+NnPg6KJ6ZwfP+d6O5Ls/n9vzJQPLb+OYRAACAyuQRAACAyuQRAACASuaRb2fbubS9xD/6DTHGs81LrDOJ5fxcT2GW5dfyIFfvUM/P5XzV/T+u0Hsbvxd5HT77x4DPvwZbJm+5t4v3iOun42Pvyc81A7q8npZPeO7/Wmbyvc/W9meSz7/+Ycc3jwAAAFQmjwAAAFQmjwAAAFQyj3x5732Jtr31CNwus/j3M5e7BMf9fl6/5zFy+4/NCO57JmM8ubxsf//wjONn34LlbeD/l/eMfa3hO2ce1xHCc6Yux7f9jLrdPjo1eG3r8ZlVMoZXR7/9hKz7k4Hki/PNIwAAAJXJIwAAAJXJIwAAAJXMI5/uaiZwm76oy1s+4nKGsfU0xvFqJvG8/23+puY/MkM4Nlge7+2+W3+7//fOFLbX/93jKvIvfKxShfrh5i3qbw/oY3sP39s2E5e9iDMSeK0ncSwfF1R+hub6z/E493j+TGunY50xXP5dg6uv/nv3SMJH880jAAAAlckjAAAAlckjAAAAlcwj7+6jM4PbzOPVS3zuL44/MoDl+Y4OsfPvcNr2La+yPh8l/1EzhMu8SL6Al29Jef6v5qcyjxPj2yU2/4fDf/D+4at57x9K2m/J31rmLawzjstQae79+fnxGfR75LZZ8Hy80ZOY+8/1Lx39D+s/58OWgczHu/Px3r2S4zOk7W95/EZGkvfmm0cAAAAqk0cAAAAqk0cAAAAqmUfe3fUexPPybaay9yyeU5P3+2lp3XxE/u7j/GRGL5fGeC6/Zdv5ydUzcJLbX8u05gDelvmUP+wglraMZlmc4yn5ke3zv5XV7xcTMO03hB+d2eTr++zfIo9r8J2LJ2smbRx+l9OuGciL97Qxnnw6uf5TyQCWe+bM5Rfj/Obi82fELZfn+Mvhb3l+y+vXMoDbjOTYfnm8kSld9kq20co88t4++zMDAACAb8DkEQAAgMrkEQAAgErmkbWrPY6tt3H2CpbxlH+pvYvL/dfexGUgYWxfOqJahq89vXsd0Hn7PH4fT8kIjvO1TeFdzHO067Xufpe3Se1yeW9/+3jwt41r/L1rGbcZsnHL3fY+7n7PP2oXM2O4/d5gZCCXvYU9NBmPzp9RrYeyZVKv9izebtc+c9bH/8s9kTKSNL55BAAAoDJ5BAAAoDJ5BAAAoJJ5pPro3sbrGceze2ToRgZyub/ZI9kynrtOrbF6PL6X9WeG8fH530fmsnRKxQDm8c8dZDVTeFz6L9bP5zee/67jLX+lNl+/7fvh2u/o3u4fe4sWb+GzfwoY1+DyopxrL3PTrVcvb5pl+9tzOf4IJcbi8rgqvYv5mXPLz8R8Prl5/kPeo2boMobXxvf4MCOGmWFsPZep9WCmEXFsHyHj5d31NjYfnYmUeaTxzSMAAACVySMAAACVySMAAACVzCPD1Uvi/u4Zt2u9jTN9kvs7H29G/M4ZzZFRHPvLfMk5Q5jPr2UOW+9ky5yOxcelf3i9R6a07b/kg8bzK+MpeaPsTHtb90qGZWa3JU4yb9R6L2slXR0PfK5Re7jOXNVy2uP+t4fLnsG8h+Xz2fc0ZoYvtczkOVd/u5gprZm4PN+3zOSdM5dj/zVnnz2WmUH970P9f5vnZ3IsLxnJWxtfHq9lbMufERjrb/df9jfH41OER755BAAAoDJ5BAAAoDJ5BAAAoJJ5ZGiXxLaHcb+/kmHMjFnpbey9jCWzVjJ8mWmsmb+RiYzFLbO4PJ/jfM0d5hbH/W3Px9WM4RhePp/SwTUypdkRdvUW2Go3lx1fMwNblsM3l5mzP7wplnss75FlpmxsPjJg51x7O8DsJSy9iGM85wxmZgBbJnKO7xaPr2Uqa0YvPwLr88/MY3wv8pyfSedexJlxPB6+ZiKvZhbXGcjt+S/72+6fn883jwAAAFQmjwAAAFQmjwAAAFQyj1zPOK57FnP78xFHpq71/NXnc17++nbOr8zEZIw3BnhvlVilJ3BmPHMP54xi6538Q6jyOL6RGSyvVyuabJnQbaYxz//4HVmuv7wFzt7RXOPi7+QyE5qn753jJj4CmJm7usHHKpnBkSmsSvdrKQJsPZQ5nszgrTOPI/NX1h8Pz/fgbcayZirfIhN5O99TRoYyx589irF93vPH/uLwt9jByESO8cX6JeN3i+sn76kzY5nnOzOlMZ5lL2l7d2wzqWO5zOOv55tHAAAAKpNHAAAAKpNHAAAAKpnHX+jdM47P5//vPzKLLQQZgYV75Enuoyew9fjl2qWn8Z7jf9xfZiJLXOXptTzhmaE7Z+pmr+Rx9/P5l/M7Mne5v/G4ZQDPA+q9oZmviIznOB3njOrcf7kg63jL7+C2vZZ1+faWLZ/C37a7RteZy7F9/kP7vXh5T7aevpFxbL2BJQM4xvs4vtZLOMa77pXM5bn7dvzMHMb4S1HhS+1xbJnN2N+IuWfG8pyprJnEcn5nhLad7xhPOd+ZsWw/A42MY2YyL2Ygx/oykT+ebx4BAACoTB4BAACoTB4BAACoZB5/oH1v3XJ/NeN4PkKun5m72es4U3an4617IOPhyDSW4+X6LZP2FhnI1+Pas7dwRBTz6ZTXY/ZSxvnPHsSy/sggjjhGvr4tY3h8OJ7wGM5y+3q9juXteMtb6lj9nNkdRqdaK8r8y0ZJXvjs8TFfo6uvySxGfHwYb9oekbrYmzhXWC5eZg7LDvMtWzNipedx1ErWrwVu8ej8GZ89ik/Pu0xm6ymc49/1bj6/vDw8zufzsuxRzNORmcyZ4Xzc4BbHu+VHXlwAo7e0HO9lvL9Kb2asPZ9v6fl8ukYG8ufxzSMAAACVySMAAACVySMAAACVzOMPdDXz+N4Zx1FzeLHHsWYcM8M4egF3vZRvMZ57HPAe+Yb7/THF2OJDub/2eoznHye4ZhxHMeV5eWYUW69i9hq2qzEzqDUzOq7vx/OfGdERMIrL663kP2aGtoyvVXAtXb1Fz0jlCMle2j+/0HjPXOuJaxnCtZKpaxuMyN3IgJUMZmY6xw5LD2K4ZaZw2ctX1x8ZucwInn8maN9CjF7Fsf05pNd6DkcGchwvl59H3DKOLVN4KxnE+Xrm/rY9ovF4Od719XJcKuP4G/jmEQAAgMrkEQAAgMrkEQAAgErm8RdoL/E2Y/feGceeOXzc3b0U+fWMY2QYY7gjgzcyj5HRLL2FM9MXy0eP4zlzN3sdM2O4yyy23sORQG3X03zBHh9GBnFEXIvZ+xnHL/9Saj5rL+Tc+3n5vSVEliegj+/v/k7w/T9BlhfEr3DtNb0aQWqvcd9/vKZXe+Syt29kHHcX5eiFXI5vmxnLDV7KEcbuMkOXZbdZy9gylVczlOPw58zk8+2xl3H2Fub+zhnAkZnM7W9xQuKemxnK7Gl8uZXnMzKO0fuYvZp5vkox6Hx9rmUgZwb0nPtv7wcZyN/HN48AAABUJo8AAABUJo8AAABU//nsAXDd5V7HyyPI/0B/zhzO8ZTMY64/InUl49h6IrP3L/b3zwwJPm5fzn8uz8zm2PtrW/+cGZ09mbH/lmkdPZYlo5j7i3zDOD+PNZhzeclf9J7R2LxkZDOTOHtOz6HTcXm3HskxwPJ6nbfumcy/HjeRWdzKe1Zmpi6f07cP/j1xecu0zGOql+w241ie/sg8Zk/jyHydexqfl72X4x6R+4t7ch4v79G3uMeO40foLY8/Xr/Rq3gez1vc5DOz9zy6iXN/0d0bA8rMYX4mjozdPV+vx8UjBx/He43tswcy3wCtt3J8psf18jKqkzMzmkdvmdZd13Ma7y8Zxl/PN48AAABUJo8AAABUJo8AAABUeh5/oPfudWzbZ0/h+P/9s5juuL/MF7RevteSiWu9kP+8RUAk8gev2fM4jve4+cwoZg/mOX9QeyFjvO383Eu+pGUYMz4z0lfbXtB8fUZmsPVmZl5n12OZgZHsyUwtT9Iykvl6Z74q41FXb8n5ejeZL7qqZW55f61HbWT6LmrvmTR69pZaj9/IiI5QW9t+hChPD+d7Js5HZgrH8d6y969kzkbmsmUyn46PW2/fyCi28YzewpdYnvfAWD96H/t4zpnLXH/0JJaezXx9X7I3M9afPZDn/Wemcoy39Dy2e3ZmJvNDOHst07Y3ct1zyrfnm0cAAAAqk0cAAAAqk0cAAAAqPY8/wDrjuN3/ON55/ZlxzOXLXsd4PDKOIzN2/g//mUkcGbqScdxmHmcmM4Y3xtMylqUHcvRWlvHWHsUcT3Z6Zf6pZDhj+5lJzU64yChmhnPbK3qPzOjIG52vhzQyhrfzeOeAzoun0ku5VsY34l1i8l/PtddkZPTeObaa75GZiTqPf9QWlkjVbeSaH/eQmbFZs1h6/Mrpzh7DzDjmCb7n+c8MZcnkPcc9cGT8MiP4ltvnZ1Asz57HuD4yE5jnOzN24+XL3sjxM8E5Uzgyo+WHiBmJPZf13u/n6+VW7sF5/WYGsvVAzvN1fr3zM/rqt0Tru0vpAeX7880jAAAAlckjAAAAlckjAAAAlczjD5D/n7xmoJa9jnPzZS/kLN6LR+cMXetNbJVjo6cxax2fzj2BLeP42sY3MpN5/Hj8mj2I50zkWxYlxvqvJaNWM501U5mPW0/nOaM4eyT/T6x/zhTm+J8jg1h7EDPTOfI8JWP8WnojlxnFuX7Jj5T321o7XaO3lL9tmyiaGcddBvFfHCEejmD68Xi1t3K8JeI9O3LIebhzpvDpNceTOfPsdYxM3LjnZuYz1z9nJGevXmQ4M+ddLojRkxghzlx+O8fan/KWlxnIfDlmTWZkJEuv48z4Zajx6Wj2GJbnm9fP6LJ+3P6l9Erm7l/G63f+meGlLG+9ls3yE6duLwP58/jmEQAAgMrkEQAAgMrkEQAAgErm8QdY97yNPMGjkR+4OJ6ecSsZsfEv53zO6FXMjN65Umtk/MZ4MnCTma/aY5l5jTw/pfdyZAjPGce2v8wAjv0tM535/DLhMV7/DKGOjGFZP89HHj4DOcvx5uHG8WLxDGTF+ssOvZrRzP2392/m3S5q7xf+vnIJ/imUdO2AJVe9zTjdbrvfa2dmb2QsR1Fkrh6Zt1ie96DMyM1u2txBHu9xQCMjlxnEzAS+xHjGPe3cS5gXSPY23vIf8vlnxjN2nxna7GHM6/OWn4mxu+fxL7d4lJnU3L71Wp6vn9GjGb2Ps6fz8eHLyzk0mjn+kcE9736dSdzbHWFcbjKOP45vHgEAAKhMHgEAAKhMHgEAAKhkHn+g2vK27nksnVfruEzmHx7/P/xrJhZGiG2b8cwMZMskPho9k60HsmQaZ8Yx8g/ZCTUyiY9GxjPzH215yzCOxznecn5K51mOP/NJLZ5VM5ft+eT+W2axXH73zFiODOd5+3bA8XYo64/F+zfs+fjLDGepNOMvmD2EF/dXdlev2ZD3vBaZyoheXpMtQzk+A0YvZexvfCSdP9MyI5n3qMyVZ89kenmdTYUP4yu9jc+R2cvT0z4Dn+Mz7BbFjePvGESmL3sU3275mZiZxvzMyc/Utv7jh8rb/eXhcashHecjM5CjG/icaRwfUbn44tuz/Z2E0YU8Mp5tj7s3dH7mykB+f755BAAAoDJ5BAAAoDJ5BAAAoJJ5/I1KnqP9//ae2Sr/Pz4zciOPkZnB7GA6H21k+kq+Z+QZWmZsLI+8x1spFcvzOXoQ43jxeo3ev2WGNcefcaGecWy9jbE8M7KjhzIGUCKuLeM4x5/7GyVjj4tLhq/1LraIbsuUjv2NYtLj6ut82Vp5Puvtr42G/8G7Z462mcTqnHmc4z/34tWu1Aj9jdrIW97D8vCZGcvM4bmXsYY6Ryw8einHPSzPX2byIjMYGcinl8w4nnsVx2f4yBye/05C7n9mRB/NW07+XYD8jI3PqHx9zpfP+Ix82Wb+8nqO5zs+c27n599Ckm/5dxWezz2X8/I7P79xzy/Xr4Tjz+ObRwAAACqTRwAAACqTRwAAACqZx1/gYkvizKjlCvn/68ceWt4h8wiRD6i9iedn9Dwyied8xAhUjMjZruhuZBhnqPG4fGQiy9FnhrO8Pi0T2jKOI7Bx7tWszqenns92fabXf5a9jGM87fXM/Mkuw5hqxnAZMLna+8j3896v+fMoPozjtWv2lpnA3H7s8bj/58h4ZSYv3yTZW5ip9ed/Hh+/5PMd96D8vfy5qzXX3vb8je7i2H8+n9vIzJ17Fp9LpvA+Xv7oQXyKXsVcfbyeGUIs4ysR2BxvqemcXbRl/ZGhHJHA7JUsf/cgMqjP5QLJ673/4YMRIi4btB5IfhvfPAIAAFCZPAIAAFCZPAIAAFDJPHLdyHC1DOKjzAtsM2DZWTQzatlBFVrP4DhgjjfzIZEwGfmJc8ZxHK6kVq9mWpvRSdU6va6OYPt61B0U+Su00eEWe6+9hmV5y5uNt0PLi5Xd/fhM43cM4Pys1ySvsZGBbO+xdo22WHq9qbfl5Rpa/5r94k3s8j0wNy89fJmBzAFEZm8sz+LLW2T8ygtW/szA+LsF1Tjc8oQuV5/jy7/zEL2c29BgC2HCX+abRwAAACqTRwAAACqTRwAAACqZx1/gw9M1NQMWq8fj7HlsIx4dSffMT0T+ovQoPpfjzfFmBjAzjpGBvD8un5m57GyKxZknyq1zg8ifzPP1FMtLT+HIjLbAxS0enXs5x8sfnvPlzOXzhJz3n/mT7AGN8c64TJ7v8/PJ8z87zXL/x4fjX0amMSvcSgdf+n4Zye823u9ve01lxrG9J8bx2j2irDDew2X8uf5t9Ebm8eNxeROPTF/JWbftM0OXvX95ftr5n/f4vAmXHsK8x9/yM+HRyERGxHLm7M/fe7TLq2Ug6+U5eiTPL2DNOMaHxnh/lfHOn2lqSBMu8c0jAAAAlckjAAAAlckjAAAAlcwj1S3+//19ZLwi/5B5h9xf/P/911jjFnmKfyKk9xYBkcwTvGUeoPQ8zshf/E7l7TGzODOCTx/qXjrSxvMpGcas7IqnNzNzGecoPZotApt5jufM2GWeJCvj4h8yQ1mN6/XxCb7cztfTPTvNSs/pyKSWjOv66Yzr/9oFuc6zhe+Xmfz52mvaexMvHn+5w33GMe4pmQEbb7LHh7fI5OVnzMjFz5DffxvqH0bXX49aNZzbt3tufgbmZ8L29c57eC4uy8fr0TKh+fr2AT6ufxs/hcR4zj+zjOGOz6RzBrT1POb5yNz9y/g7CzmeMv51G/Qup799f/P9+eYRAACAyuQRAACAyuQRAACASubxF5o9g3WLh0fj//vH8hGRK4e7ZaYslo8IYuY1zruvRp6idCaN/+8f+Y7ba2Y0S/5jjCjyEffM48T5br2FI59xDjiNDGHmazLTFnmM2y2ef2YUY/vMx7TMXGYQZ/znnMkd1/9YHovjH17+MwItcfzz7+QyMpmvZ72iS6/kdvuh5uOubT/3t1r93X2HuM4+B7s8QFv/HEvur3nNMLbevhhA3NQyszgPv7unPUcwvET0xj2k3WPzTZt/B2DuPj+jHpfnPXT0KI4gezzftv/nsv/xGXe+R7cMal4O4zM6xzOOfz7/+TNLy1TO55s/lDwdvSx7I/MzPD8jZm/k+fwPs4z4uHrtUS2P+Xl88wgAAEBl8ggAAEBl8ggAAEAl8/gDzPxALX06Lp6ZpnMnVM/jnHsWR8Zx+TjzAKMTaeRJsrcx9t96FKMzKte/Z4/gU3aGRT4mls/M5zjA4/KSmZuvdvRkjt8hRWYxlmZcYizPjGgePvJJ9+hZbHmtt9I72vIfb+VXZnk9v+ULkuuPPFSeoHOP5Lz+b8flecHVuNo2kHIx/7LNVH+HzOFnu3qO+mtWjtdyspk5W2Y0byXz+Jb3rJGxbD2MofTs1R7GfM/Pm8Bx/dGDWM/XOeM5MnwtZ18yiuN443zl8fIz79HI6GWmMrqHZ2ayjSe3L93TI2MY25eMZmYqZ0Zzpk5XRjf1bvtxvZXH82e0/DsPpTf1uLc//My23B9fj28eAQAAqEweAQAAqEweAQAAqGQev6GWKWrxiRlnOWcSP7qGbVRu5fLMMP4hZffwKDuRRh7lMWDx+hYZyJGZa51Q5w6me+Y3Rq/lOQOZnWP3kaGMfMLruZfz/pT7e3z+o2Mq8yIv2XOYvY6R2YvM5nPkp17i+b29ZU/meTwj3pSZxXz+md+K/NBbFjGOgEhmKOP8jNBkjv9x+5fyDhs9li3PVUv1Wso47e4A207CT655/BF6jPVaD+MsqgvZszhe1NJjWDJxT3HPGpnEcU/YJeczxz4yi/n84h4xun7jaPkmzozk80tmBuP5xD1kZA5vmfFbrp89l/l8xvNrmcs8/tPxcWZeZ0az9YDG45GRzf2fP5NHxrCNP67PfLu0jGn7uwUzs3g+X90uszh7Ja/sXcbxJ/DNIwAAAJXJIwAAAJXJIwAAANXz27aUiy9nm4GccZS34/J2gYwMWWbMYv/5ODOM2ROYPYDZu/c6S4SOx3vNSFpkHt+iR/GfXB6Ha88nz8898i5vmUGLAd7H+cnVz5m7jPBlh9Rrvn7L6yGff8qOtjy/T5kZjPGOzOWIO+Xzz0xpXp/n46X5/srxnFPC+fpnHuk+MojH3f0LuyLWd/8AKK8XU+1ZXJoRqo99Ecbey3tiu8PRA5n3iOfWA1l6G0fvXGb2RrFjDuC4/szN5+hKb+Ho0SyZzZLpywO0VsKZGUyZkczDld7Gsbtz5jJP/+x9jFx5yRyO7WNA+XcXRi9lLP/Py/l4KceXl+9LntBQezWXy2tmMR8ve0X5/nzzCAAAQGXyCAAAQGXyCAAAQCXz+AuMTNh2eT/Cw6OZcYyM3zheZOJi75npGz17kSnLDGT29o3938/jyfG/3h8zkLnD7GG8j56/drxzBjR7EOf6MZ7XWL9l/kpmtd0x2vU1ekWfy/UxMnQlw9cyk1lJNzK7uUKOJ3soY/VyfabcX+uVHL2a6zzJOZPZtM61JjPM7LXeuK1xDRbbTGEzM1HjplrWj+WZkav7i0xf2z4jhOUeWsebj0eGL3scz/fQmkEbj0sPZBnfyEyW7WdG8dxzOTN6kUGMzGdmPEfmM8Z3G93Q5/2/ROhx9no+/svLyDSfX5+RGS3vr9b7mG/X9fU4VthdX3NzGcjvzjePAAAAVCaPAAAAVCaPAAAAVDKPP9C697Fk5ub25zVmr+Djw5kxLD16Zf9jeWYISydY9gL+cz/3PmYvX2bassZw9CDe82HJ+I3luX1mBmP8WUnW9ld7OEM+4ciPzMxkyTeNTGH0Io4ndO5dHBnF8fzyd2jn/Nd8f2WmNTOMsf24/tMIUZ5lhrX0nnYf+ztFHznXPY/c77XXrN3TR+avGb2EecCSecpbSjt+O97IaJ67V/P8Ppd7xOyBPGcUR49h7YU8r5+v38gIll7InqkrPZSlp/L2/JgRHM+v7u+cYWy9mCOTOXogM/P6dF5e9pfjnz2Tj0qN4x9e/3Nms/aOLl+//Axpmc0k4/jz+OYRAACAyuQRAACAyuQRAACASubxB1pnHuPx/O/u2x7I7JF7XJqJsprxq5m/8//3n72L7fiPXktmLnvyMs7TevTy+c2MZvYyZt6ljC8PmOM91xr23sXY/czU5gBK5rX0go4LNDOnZTy5xn38Dq30WpaM5Vhe8lotQznjVufe0mq9wS6vst39/hPo5/VEXu3OfH/nczwychcjTdkL+Ieb1uP6taduhOAeH5cM19jfyNDlAM+vX8sMjg+lkcFrGcWWaTs/nuM772D0Wt7y/MT6NbNYMpel9zAjf9nTmc9v1pQuM4sj05nPLwd87qFsmcMxnvGRFccfl+PFntHj1vu3vwzk9/fVPrEAAAD4gkweAQAAqEweAQAAqGQef4HtS9x6IPM/5PeeyHOmr/VAzoxZ9gg+rt4ykDne18wMxvC2PYiZcRzjyf2X3r+W+RyZx+x9rL2Cj0ZGc9QOlh7FkWksPYi1p7GMuGY+/2ijAAALqklEQVQ4xwmLhyOk+vh4BGTy8Oc80uilLGZGcpk5fOdbesuM8hOce+y2xvbrjNOuhzC1HsTcXWbQagZxhOxiAKV3cpuxbD2RMzMbGdHSEzh6NOPh7bbMwI31z72ZtXew7D+9ZK9kZg7L85/n/5zRHBnEUHspSwZy9oDG/tcZx9J7usw8jl5RfjzfPAIAAFCZPAIAAFCZPAIAAFDJPP5AV1/StnXPOJ731zJ1vYfx3CxYahVHBnFmGHM85+PNTGSuXXohR+bveLh/0bN4zhw+jYxiOd54vpHpi9KvzFjm76jesiSsHi8HmJYZy9x62dvYtt9qPZB/2CCOnyss8yd5fkqeiF+oZAC7zFTF4uUlt81AZuawXuMlc1h76kam7pxBLIevvZrPGfTO42Xv4dsuwzkyoWGej3NmtfUKzud3Xn9mMs89jbnDsX7m+mPzl5bZLJfHLXdYejZrT2e5nrcZx7R9t+tx/Pl88wgAAEBl8ggAAEBl8ggAAEAl8/gLfHQGcmYCS8fWsvexZSBH6Cv+//+9ZN5ey/nJ5zee78gv7LYf56dlMEuoc5uZLLWYM7NZM5Stl/JR5onm1VICMLl963Ec+99lQJvZi7rbvlSurdX3/zqeIs/y3q6e0esf4tdyt+m9M0/b3Y2M43oHx4ejp7ANYGT2MqOWPYIjM5j7P/cGjtVHz+Auk1nHPw94Xlx6JMf6IwOYmcZc//H53cZnXJ6P7IFsPZK7jGHPuGav49Pxcf7dgZqR/eCMY5J5/Pl88wgAAEBl8ggAAEBl8ggAAEAl8/gLvXcGcjxuvXgz9fbwqGX6zgnG/6EXMjOaNZN4/v/8maHMTF+Of/Yy5gqPW+R4x+pjB5HpG+OJXsRyhkuE9OltZBKPD//F9Zjnp2Vqc/9l92WD2oNZOsTG5q2ItDpnTsfxyvgufwD4CPl+So/cenftpjw2OPcS7gdQMoDlnp0Zw7n7XS/jWF5y8aOXsfxev/UAzph4GX8cbtY+Zq9hjHecgHMPYpOZw9oLWWpIs5dxKL2PmTmcT6dkCEsvai6dGdDz+MZolidcxpEt3zwCAABQmTwCAABQmTwCAABQyTxyOQO5TavMHr6ScWwZsW0vZI6nZfzyccvwlYzkPTNncfjWEzh6EUf+I3sPz5nBe0u9xfjuJa8zTnc8/9EjmScgl4/Xf5l5LL2Xc4N82K6fnbn+xVvwW2ZW08U8WRBv+Xmu/hQwM4YtY3a+JltGr/YInhev1UxXiT233smx+3LPGj2Nszz3vP+ncw9i+1rhNnoMxwDj4fnvCtQMY+tlzAGXDOPMUD4dl6eeSX18PHojtz2XdTxlee2l3JFxxDePAAAAVCaPAAAAVCaPAAAAVDKPDNtLYhkhq+7L3r/02noI5xEfl2dGb/kMc+2RwWzFhyOTGJ1aJQ+UmcqMF83Tc86r1N7D3Ft9wc+9lZmBfCu/4xqZyHj+mem8lTxWnu9hm6HcGpnVsn7micbr1/Jl/3Zg/FQtYpjqNdMyUSPzdnF/W8v38MjUhXvcZDMTmCf4uWTc8h7ZewJj/8sM4+yJPH8G5Ba5+7zF3jL3Pw63yyiOjOk79zKO45cT2l7Nl/o1TT7fkold7e36+jKOJN88AgAAUJk8AgAAUJk8AgAAUMk8UrVOpt6jWPZfj5/rl17GzCyO8UcmbmQcd2aP4mx+3Ow/n29m9jIPMWsly+uR+6/5pXNv5FPmcUYG8TyAzFvl81tncEfGLzvAHh/2jO1y/7l+/Zdrt+C83v/FAOBdzZ7CXaZv3FJqxOpcjNgTWtkte86cpdbbl/vPXsZqnICSe2/jueXzO98UMqM4h1eKLXP90fVbjr/M/LVM48wQ7jJ8ufZtfCae998ynWO8F3tMryYUZRxpfPMIAABAZfIIAABAZfIIAABAJfPIZVcvoW0CbJ+xPOcTrvZa9q0zY3nxLVcDQiVjWWomR8Z0WwRYM3jZ43jOw8zDl86wcn5HYjbzK+Noy/zHshezluaV8/nZN3DpmO/n6jXz3q/5eM9dfE/U3sI6oJbbLpnIsfq5F3F088by1u3bM2rn5zMygedax3/Ru1hW+MNd9uFR+bsK2wzjOWG41zKN2wzuZ48ftnzzCAAAQGXyCAAAQGXyCAAAQCXzyGVXeyDH/pbLr++/BDwys3iumfxDj+Bm71N7fhlf6JnKbd4kM4q73dfRlONV2/zT2Hzb89hWWO3u6a38Cu/Db9Etowppm4MubiNn3Q6/6y0cXbRzh+fl4x5+MYfcehFbpq9kAHvG8bj20y3O17wHtc/M3F/e44+rrzOApdayviC3cXksM5q5tGU2R6/o2fYOvT0+bPnmEQAAgMrkEQAAgMrkEQAAgErmkQ+3zUTuEoj/JoO37N3b7r9uEb2ELQQ5ehrPeZEa78gM4TLvsL5DtB7OzN9czTyW8zNXP5/fNDvGdt45Inl5Dy2vJQ1D6tfwx/4YMTKA77y/+1PLpF3rnZyZw+35Ku/ZWZS53P9288xgPt6zn0vGtA13np883uPDWw05Xjwf2+UXexvb6GUa+Wy+eQQAAKAyeQQAAKAyeQQAAKCSeeTTXb0Er17A+57FXYnUzAPV5sGy/Jzxa+dz5k3O45u9mqe1/wfrTOZ5fGN/7fDvnPLb578uXsFX3wAzsHNxh3w3vebwg39M2N4Sl5mulvPt2198/pEBnJ8g7QXITNvu9/41U1cjm5ETb926LfNXcv5/GsF/f7R3NTN4PXN7cXuZRj6Zbx4BAACoTB4BAACoTB4BAACoZB75dtaZvra/S6P5U+YuQ4G7HsHLx2/rj+Ht8iajp/Kd9fE8GpnF5fDe+9m08Y+M6tzDteMv1//sDwDpnc9/Db6+c9dqjYCVjN77ti72DbYZypYx3Bo9lssMYo7nagSvb748XvkM/uzM4tifDCPfjG8eAQAAqEweAQAAqEweAQAAqGQe+fa2HU3bS/5vv0HG+JZ5iPd/S2ev4vvuf5/BjO23CZSPrqxbZx7Pea73zjvBl3MxE9gycR/9HtpH1q5lHr9aRK42824HvPxMf2/bo21/5pBx5LvzzSMAAACVySMAAACVySMAAACVzCO/3me/BT766PX5vXf+4sMzpdcymF8tbdLH/9VGDB9t+57+2++RkUxerp8+ujv34vkp9/S5+w/OmH7o3v/F8WUW+eV88wgAAEBl8ggAAEBl8ggAAEAl8wh/2Ue/5T7/DZ0jKB1YZW/7dMm2Q219gLO/nIfp4//8K+JMfujrv0bv7S/3Fo6evePiy65mAK/fE3f34K2v9o6VQYS/yzePAAAAVCaPAAAAVCaPAAAAVDKP8Mne+y340fmPr37L+Nqj+wjnZ/zXXy75o6/nq71n4xr56VfMV39+X/0zQ6YRvhbfPAIAAFCZPAIAAFCZPAIAAFDJPAKfyi3oUcv3OF/8Nu/fBfuzyAQCf5NvHgEAAKhMHgEAAKhMHgEAAKhkHgEAAKh88wgAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAEBl8ggAAED1fwG9DlwMICsoGAAAAABJRU5ErkJggg==" + id="image32" /></g></g></g></g><g + id="g34"><g + id="g36" + clip-path="url(#clipPath40)"><g + id="g42" + transform="matrix(46.2002,0,0,68.2002,5089.9,2243.9)"><image + width="1" + height="1" + style="image-rendering:optimizeSpeed" + preserveAspectRatio="none" + transform="matrix(1,0,0,-1,0,1)" + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAJCAYAAAARml2dAAAABHNCSVQICAgIfAhkiAAAABVJREFUCJljDG+e/Z8BC2DCJjgEJQApsgKGNoZXLwAAAABJRU5ErkJggg==" + id="image44" /></g></g></g><g + id="g46"><g + id="g48" + clip-path="url(#clipPath52)"><g + id="g54" + transform="matrix(1164.2,0,0,241.2,3829.9,1162.9)"><image + width="1" + height="1" + style="image-rendering:optimizeSpeed" + preserveAspectRatio="none" + transform="matrix(1,0,0,-1,0,1)" + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJsAAAAgCAYAAAAFbAo6AAAABHNCSVQICAgIfAhkiAAAAptJREFUeJztnEGW3CAMRA32wXOdbOaGDVn1S/wrjxrSjlZVO8ZCErQG1ROy24+fX/N4AuOupvX2kbrWNue3u30xP7uxt2numPwDhuKAaPgTvd39w/BoA+MOeZjr6tBqeBwdv98B/RPPTzgI//qp89e/QBA8iARbUIZryvH+j8C5POdneneTMOVfTDvN+cM0svagU5+k6T17/Lcfr/X8NrFADE+kWc369/WdJs9O5mnqE3OS93OyBXVIsAVlSLAFZbjGQ5TtcYCTdFebmOvnjkMKpaM6CIzXXUA4C+Qb/cN6xnitHYD/c8A+9M1x3z+WSmQ98I+lHZZSlOtzPuRbSh9BIRJsQRkSbEEZrnfqlbqIYM2JXF1qF8JBDLckh9DrLkfK1vrkOkbKTneBUzgkx3f5gTqd1PHc9tJdXl8NKTxCgOa4QMbHfQJ3dxzk3KmzBYVIsAVlSLAFZbi+L7omTcKZPo3jPYr1DQWbBnbtg6MJZzLTRR0p27OU2NsXDs9Q4Qb5/czJFpQhwRaUIcEWlGGDs63vvnbxNAdx+vTxngPSZm3sa1v2uo7Ju0hdz3r/WVfkKcLn0rYvQ2rg3S3b0v0PkJMtKEOCLShDgi0ow/XO1Y099P+5rqNVsPU7AJ7jQZ6NeubVQu2XW9fpKM8645B+MHAce5drYMSlSob1s47X9bIX9nAXasKF7XPHkZMtKESCLShDgi0ow+9Ubu/+Pr6sXKKxid/Jy3z8gZ8HcPLSrkXOaDiljNd3h1L3cvtpPu9wir+su5n+NXkvdN1PR326n9qfl5MtKEOCLShDgi0ow/XO/ZaRufYwcoZNR3ydaY8zujqYcDDD8aSOZzicm9/NtzgIqYMZzsT1209k8Z2JTs5H8+YTW3+pw/0Cz1qdBIkHZ80AAAAASUVORK5CYII=" + id="image56" /></g></g></g><g + id="g58"><g + id="g60" + clip-path="url(#clipPath64)"><g + id="g66" + transform="matrix(1163.2,0,0,241.2,1918.9,1162.9)"><image + width="1" + height="1" + style="image-rendering:optimizeSpeed" + preserveAspectRatio="none" + transform="matrix(1,0,0,-1,0,1)" + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJsAAAAgCAYAAAAFbAo6AAAABHNCSVQICAgIfAhkiAAAAoJJREFUeJztnFuO2zAMRS3Z+wdmL4Ourpb7OdC5UzKuXCIY3PPn6J3Q0g0pqX18/rq2gGuEydtoyP97zn8xXSoYc3pHfhQYyK81NqQiXR7RwZtI/zb2jwXutXexgZvI79fXxiv13+hfz7MY8ww2NlPGoevaDJO5iskyhVn6ZAEuq5yGz7j+bNrW1LnBIeXvLVPZqiHp2XgzRixLUiT/4rIsMsTLqHlDbGymDBubKeO4IAJGmzXWgGZo1ADUFFjURXPxrzhFgDTA+kQEzY+NonJ+n1RTsT+xKFLNl3BT44gmetZTsSrZvqnv9Q56ZjNl2NhMGTY2U8axMbxyzc8NGoqaixpNgjWZRkj8QEM0AVuY3xdKsFPyQ6NKchzeIqrB8H0thptEwT6subT/T4u6LzyzmTJsbKYMG5sp4/h/K/S/sapJVDMmfqDYrSfkscDFAdyTjC9Ul2x5KsQzmynDxmbKsLGZMo5Ovxbs74LfqXFbMSRBp9+Nsc5EkzTxczE2ivcDsdCOWOhoLD8/d8aGE5XUEpGn40Hum37HLPKY1dfeaD55n56YH4+NzZRhYzNlHFuLz84xNir726DhzjMVEWgvzkDN0yR2Co2WaK6+xfk7Xz9quuSQQWPsuHM8ydHIp0VdNUH3PbOZMmxspgwbmynjEAkgRwKgOWCe3A+2q+iZns54e9kLsUkWnz/Zxa0Xi5pOP1xyX8TeuV8NFbIDcu41vh5i5xdMVs+RZizWF/18ntlMGTY2U4aNzZRx0NoGY59chDs0hlxRRb8UisuVTfCbJVd0qd8vuUKLogaxVMY6RePtYXfET8f6OBz1G5Lkyi/67eLufXNFWewnXd/f+Pca/gDoSxDFoFyCJAAAAABJRU5ErkJggg==" + id="image68" /></g></g></g><g + id="g70"><g + id="g72" + clip-path="url(#clipPath76)"><g + id="g78" + clip-path="url(#clipPath82)"><path + d="m 3323,1912 v 1 h -61 v 1 h -42 v 1 h -32 v 1 h -28 v 1 h -27 v 1 h -21 v 1 h -21 v 1 h -20 v 1 h -18 v 1 h -16 v 1 h -16 v 1 h -16 v 1 h -16 v 1 h -13 v 1 h -13 v 1 h -13 v 1 h -13 v 1 h -13 v 1 h -12 v 1 h -11 v 1 h -11 v 1 h -10 v 1 h -11 v 1 h -11 v 1 h -11 v 1 h -10 v 1 h -9 v 1 h -9 v 1 h -9 v 1 h -9 v 1 h -9 v 1 h -9 v 1 h -9 v 1 h -9 v 1 h -8 v 1 h -7 v 1 h -8 v 1 h -8 v 1 h -8 v 1 h -7 v 1 h -8 v 1 h -8 v 1 h -8 v 1 h -7 v 1 h -7 v 1 h -7 v 1 h -6 v 1 h -7 v 1 h -7 v 1 h -6 v 1 h -7 v 1 h -7 v 1 h -7 v 1 h -6 v 1 h -7 v 1 h -6 v 1 h -6 v 1 h -6 v 1 h -6 v 1 h -6 v 1 h -5 v 1 h -6 v 1 h -6 v 1 h -6 v 1 h -6 v 1 h -6 v 1 h -5 v 1 h -6 v 1 h -6 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -6 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -6 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -5 v 1 h -4 v 1 h -5 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -5 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -4 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -4 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -2 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 2 h -1 v 1 h -1 v 2 h -1 v 2 h -1 v 1 h -1 v 2 h -1 v 2 h -1 v 2 h -1 v 2 h -1 v 2 h -1 v 2 h -1 v 3 h -1 v 2 h -1 v 2 h -1 v 3 h -1 v 3 h -1 v 3 h -1 v 4 h -1 v 3 h -1 v 4 h -1 v 7 h -1 v 6 h -1 v 30 h 1 v 9 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 7 v 1 h 25 v 1 h 30 v 1 h 14 v 1 h 13 v 1 h 12 v 1 h 8 v 1 h 7 v 1 h 8 v 1 h 7 v 1 h 6 v 1 h 6 v 1 h 5 v 1 h 5 v 1 h 5 v 1 h 5 v 1 h 5 v 1 h 4 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 4 h 1 v 3 h 1 v 3 h 1 v 4 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 4 h 1 v 3 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 5 h 1 v 4 h 1 v 4 h 1 v 5 h 1 v 4 h 1 v 4 h 1 v 5 h 1 v 5 h 1 v 5 h 1 v 5 h 1 v 4 h 1 v 5 h 1 v 5 h 1 v 5 h 1 v 6 h 1 v 5 h 1 v 6 h 1 v 5 h 1 v 6 h 1 v 6 h 1 v 5 h 1 v 7 h 1 v 6 h 1 v 6 h 1 v 7 h 1 v 6 h 1 v 6 h 1 v 7 h 1 v 8 h 1 v 7 h 1 v 7 h 1 v 7 h 1 v 8 h 1 v 8 h 1 v 8 h 1 v 8 h 1 v 8 h 1 v 8 h 1 v 9 h 1 v 9 h 1 v 9 h 1 v 9 h 1 v 10 h 1 v 10 h 1 v 10 h 1 v 10 h 1 v 10 h 1 v 11 h 1 v 11 h 1 v 11 h 1 v 11 h 1 v 12 h 1 v 12 h 1 v 12 h 1 v 12 h 1 v 12 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 13 h 1 v 12 h 1 v 13 h 1 v 12 h 1 v 11 h 1 v 12 h 1 v 11 h 1 v 11 h 1 v 11 h 1 v 10 h 1 v 10 h 1 v 11 h 1 v 9 h 1 v 9 h 1 v 9 h 1 v 9 h 1 v 9 h 1 v 9 h 1 v 7 h 1 v 8 h 1 v 8 h 1 v 8 h 1 v 7 h 1 v 7 h 1 v 7 h 1 v 7 h 1 v 6 h 1 v 7 h 1 v 6 h 1 v 7 h 1 v 6 h 1 v 6 h 1 v 6 h 1 v 5 h 1 v 6 h 1 v 6 h 1 v 5 h 1 v 5 h 1 v 5 h 1 v 5 h 1 v 6 h 1 v 5 h 1 v 4 h 1 v 4 h 1 v 5 h 1 v 4 h 1 v 5 h 1 v 4 h 1 v 4 h 1 v 5 h 1 v 4 h 1 v 4 h 1 v 5 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 4 h 1 v 4 h 1 v 4 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 4 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 4 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 3 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 3 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 1 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 2 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 4 v 1 h 3 v 1 h 4 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 4 v 1 h 5 v 1 h 6 v 1 h 5 v 1 h 5 v 1 h 6 v 1 h 5 v 1 h 5 v 1 h 6 v 1 h 5 v 1 h 5 v 1 h 6 v 1 h 5 v 1 h 5 v 1 h 6 v 1 h 6 v 1 h 6 v 1 h 7 v 1 h 6 v 1 h 6 v 1 h 6 v 1 h 7 v 1 h 6 v 1 h 7 v 1 h 7 v 1 h 7 v 1 h 8 v 1 h 7 v 1 h 8 v 1 h 9 v 1 h 9 v 1 h 9 v 1 h 9 v 1 h 8 v 1 h 10 v 1 h 12 v 1 h 12 v 1 h 12 v 1 h 12 v 1 h 11 v 1 h 14 v 1 h 20 v 1 h 19 v 1 h 20 v 1 h 33 v 1 h 99 v -1 h 30 v -1 h 22 v -1 h 22 v -1 h 15 v -1 h 13 v -1 h 14 v -1 h 13 v -1 h 13 v -1 h 10 v -1 h 9 v -1 h 10 v -1 h 9 v -1 h 10 v -1 h 9 v -1 h 9 v -1 h 7 v -1 h 8 v -1 h 7 v -1 h 8 v -1 h 7 v -1 h 7 v -1 h 8 v -1 h 7 v -1 h 6 v -1 h 7 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 6 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 6 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 5 v -1 h 4 v -1 h 4 v -1 h 5 v -1 h 4 v -1 h 5 v -1 h 4 v -1 h 5 v -1 h 4 v -1 h 5 v -1 h 4 v -1 h 4 v -1 h 5 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 4 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 3 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 2 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -1 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -2 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -4 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -3 h 1 v -4 h 1 v -3 h 1 v -4 h 1 v -3 h 1 v -3 h 1 v -4 h 1 v -3 h 1 v -3 h 1 v -4 h 1 v -3 h 1 v -4 h 1 v -3 h 1 v -3 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -3 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -3 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -4 h 1 v -5 h 1 v -4 h 1 v -4 h 1 v -5 h 1 v -4 h 1 v -5 h 1 v -4 h 1 v -4 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -5 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -6 h 1 v -7 h 1 v -8 h 1 v -7 h 1 v -7 h 1 v -8 h 1 v -7 h 1 v -8 h 1 v -9 h 1 v -10 h 1 v -9 h 1 v -10 h 1 v -9 h 1 v -12 h 1 v -13 h 1 v -13 h 1 v -14 h 1 v -18 h 1 v -23 h 1 v -34 h 1 v -70 h -1 v -36 h -1 v -22 h -1 v -20 h -1 v -13 h -1 v -13 h -1 v -13 h -1 v -13 h -1 v -10 h -1 v -9 h -1 v -10 h -1 v -9 h -1 v -10 h -1 v -8 h -1 v -7 h -1 v -8 h -1 v -7 h -1 v -8 h -1 v -7 h -1 v -8 h -1 v -7 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -6 h -1 v -5 h -1 v -5 h -1 v -5 h -1 v -6 h -1 v -5 h -1 v -5 h -1 v -5 h -1 v -5 h -1 v -5 h -1 v -6 h -1 v -4 h -1 v -5 h -1 v -4 h -1 v -4 h -1 v -5 h -1 v -4 h -1 v -5 h -1 v -4 h -1 v -5 h -1 v -4 h -1 v -5 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -4 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -3 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -2 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -1 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -2 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -4 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -4 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -4 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -4 v -1 h -3 v -1 h -3 v -1 h -3 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -3 v -1 h -4 v -1 h -4 v -1 h -3 v -1 h -4 v -1 h -4 v -1 h -3 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -3 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -5 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -5 v -1 h -4 v -1 h -4 v -1 h -5 v -1 h -4 v -1 h -4 v -1 h -4 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -5 v -1 h -4 v -1 h -5 v -1 h -5 v -1 h -6 v -1 h -5 v -1 h -6 v -1 h -6 v -1 h -6 v -1 h -5 v -1 h -6 v -1 h -6 v -1 h -6 v -1 h -6 v -1 h -5 v -1 h -7 v -1 h -7 v -1 h -6 v -1 h -7 v -1 h -7 v -1 h -7 v -1 h -7 v -1 h -7 v -1 h -6 v -1 h -8 v -1 h -8 v -1 h -8 v -1 h -8 v -1 h -9 v -1 h -8 v -1 h -8 v -1 h -8 v -1 h -10 v -1 h -10 v -1 h -10 v -1 h -10 v -1 h -10 v -1 h -10 v -1 h -11 v -1 h -13 v -1 h -12 v -1 h -13 v -1 h -12 v -1 h -15 v -1 h -16 v -1 h -16 v -1 h -16 v -1 h -19 v -1 h -22 v -1 h -21 v -1 h -28 v -1 h -30 v -1 h -41 v -1 h -59 v -1" + style="fill:url(#radialGradient92);fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path94" /></g></g></g><path + d="m 3831.69,5866.22 c -101.83,0 -204.44,-6.91 -305.04,-20.48 -421.3,-56.93 -835.54,-232.21 -1166.48,-493.5 -372.9,-294.42 -613.49,-673.06 -695.86,-1094.83 -24.43,-125.41 -34.9,-264.16 -45.06,-398.26 -15.82,-210.03 -32.19,-427.12 -98.96,-568.97 -45.69,-96.95 -113.71,-161.56 -297.75,-161.56 -101.3,0 -193.9,-57.31 -239.04,-147.9 -45.215,-90.67 -35.289,-199.1 25.67,-279.99 622.88,-826.31 1672.09,-921.86 2407.2,-921.86 18.22,0 36.37,0.08 54.21,0.15 12.79,0.08 25.91,0.08 39.48,0.08 20.09,0 40.95,0 62.51,-0.08 23.58,-0.07 47.93,-0.15 72.98,-0.15 342.58,0 705.09,16.98 1035.57,140.22 275.41,102.69 537.55,268.9 758.21,480.72 238.57,228.95 413.7,499.09 506.46,781.01 104.4,317.53 130.61,636.6 77.87,948.47 -49.33,291.46 -165.12,564.71 -344.2,812.12 -202.2,279.36 -478.85,512.66 -800.02,674.6 -324.58,163.65 -686.87,250.21 -1047.75,250.21 m 0,-267.11 c 1203.56,0 2274.88,-1074.5 1860.33,-2334.84 -166.99,-507.62 -631.95,-918.76 -1104.21,-1094.89 -307.91,-114.88 -667.7,-123.4 -942.26,-123.4 -48.01,0 -93.46,0.31 -135.49,0.31 -14.12,0 -27.77,-0.08 -41.03,-0.15 -17.38,-0.08 -34.98,-0.16 -52.66,-0.16 -689.89,0 -1642.62,84.23 -2193.83,815.53 755.34,0 606.28,844.15 704,1344.72 148.91,762.86 903.63,1275.92 1635.86,1374.81 90.05,12.17 180.1,18.07 269.29,18.07" + style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path96" /><path + d="m 3488.12,4268.5 c 0,-141.7 -114.87,-256.57 -256.49,-256.57 -141.62,0 -256.49,114.87 -256.49,256.57 0,141.7 114.87,256.48 256.49,256.48 141.62,0 256.49,-114.78 256.49,-256.48" + style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path98" /><path + d="m 4686.45,4268.5 c 0,-141.7 -114.87,-256.57 -256.5,-256.57 -141.62,0 -256.48,114.87 -256.48,256.57 0,141.7 114.86,256.48 256.48,256.48 141.63,0 256.5,-114.78 256.5,-256.48" + style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path100" /><path + d="m 3821.59,2893.1 c -434.63,0 -826.15,284.94 -897.97,657.92 -13.89,72.44 33.51,142.48 105.94,156.36 72.44,13.96 142.4,-33.51 156.36,-105.86 42.19,-219.27 304.81,-450.23 654.76,-441.08 364.52,9.62 605.42,227.63 654.9,437.98 16.91,71.73 88.89,116.25 160.55,99.42 71.82,-16.91 116.33,-88.8 99.43,-160.62 -40.33,-171.72 -151.01,-330.79 -311.48,-448.06 -167.06,-122.16 -373.36,-189.86 -596.34,-195.68 -8.69,-0.24 -17.45,-0.38 -26.15,-0.38" + style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path102" /><path + d="m 2002.27,1809.31 c 0,292.55 237.18,529.65 529.65,529.65 292.48,0 529.65,-237.1 529.65,-529.65 0,-292.55 -237.17,-529.65 -529.65,-529.65 -292.47,0 -529.65,237.1 -529.65,529.65" + style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path104" /><path + d="m 2357.13,1809.31 c 0,96.56 78.26,174.81 174.82,174.81 96.48,0 174.74,-78.25 174.74,-174.81 0,-96.56 -78.26,-174.82 -174.74,-174.82 -96.56,0 -174.82,78.26 -174.82,174.82" + style="fill:#494b4c;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path106" /><path + d="m 3840.63,1809.31 c 0,292.55 237.18,529.65 529.65,529.65 292.48,0 529.64,-237.1 529.64,-529.65 0,-292.55 -237.16,-529.65 -529.64,-529.65 -292.47,0 -529.65,237.1 -529.65,529.65" + style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path108" /><path + d="m 4195.48,1809.31 c 0,96.56 78.26,174.81 174.83,174.81 96.47,0 174.73,-78.25 174.73,-174.81 0,-96.56 -78.26,-174.82 -174.73,-174.82 -96.57,0 -174.83,78.26 -174.83,174.82" + style="fill:#494b4c;fill-opacity:1;fill-rule:nonzero;stroke:none" + id="path110" /></g></g></svg> diff --git a/app/static/images/logo/mainlogo.svg b/app/static/images/logo/mainlogo.svg new file mode 100644 index 00000000..735f44ba --- /dev/null +++ b/app/static/images/logo/mainlogo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425.25 381.24"><defs><style>.cls-1{fill:#fbc44a;}.cls-2{fill:#24396a;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M41,50l17.61.12q8.73.07,14.11,3.84T78,66.23a16.27,16.27,0,0,1-2.7,9.42,15.59,15.59,0,0,1-7,5.57A24.71,24.71,0,0,1,58.66,83l-5.18,0-.12,15.25-12.72-.1ZM58.14,72.89a7.75,7.75,0,0,0,5.56-1.73,6.69,6.69,0,0,0,1.89-5,5.2,5.2,0,0,0-1.93-4.53,9.86,9.86,0,0,0-5.73-1.45l-4.29,0-.09,12.73Z"/><path class="cls-1" d="M93.44,81.88,79.17,50.25l13.62.1,3.47,10.09,2,6c.24.65.49,1.36.76,2.15s.54,1.66.83,2.6h.3q1.13-3.32,2.34-6.42c.15-.44.65-1.87,1.51-4.28l3.63-10,13.31.1L106.17,82l-.12,16.57-12.73-.09Z"/><path class="cls-2" d="M125.49,50.59l12.73.1L138,88.13l18.2.13-.08,10.66-30.93-.23Z"/><path class="cls-2" d="M164.71,50.88l31.08.23-.08,10.65-18.35-.13-.05,7.55,15.68.11L192.92,80l-15.69-.12-.07,8.59,19.1.14-.08,10.65L164.36,99Z"/><path class="cls-2" d="M205.86,51.18l13,.1,10.5,21.53,4.51,10.84h.29c0-.54-.16-1.53-.35-3a135.8,135.8,0,0,1-1.14-15.4l.1-13.91,12.14.09-.35,48.1-13-.1-10.5-21.61L216.54,67.1h-.29l.41,3.93a120.41,120.41,0,0,1,1.08,14.44l-.1,13.91-12.14-.09Z"/><path class="cls-2" d="M255.58,51.54l14.21.11q11.54.08,18.09,5.9t6.45,18.11q-.09,12.28-6.61,18.23t-17.54,5.86l-15-.11Zm13.19,38q5.85,0,9.16-3t3.38-10.93q0-7.77-3.18-10.72t-9.16-3h-.74L268,89.52Z"/><path class="cls-2" d="M324.86,89.94l-13.47-.1L309,100l-13-.1,15-48,15.39.11,14.3,48.21-13.46-.1Zm-2.22-9.86-.79-3.33Q320.92,73,320,68.44l-1.58-7h-.3l-.46,2.07q-1.68,8.13-3,13.15L313.76,80Z"/><path class="cls-2" d="M371.11,100.49,362.94,84.3l-4.88,0-.12,16.13-12.73-.09.35-48.1,18.21.14q8.66.06,13.92,3.69t5.2,12a16.26,16.26,0,0,1-2.13,8.42A14.78,14.78,0,0,1,375,81.87l10.3,18.72Zm-13-26.29,4.59,0a8.79,8.79,0,0,0,5.74-1.58,5.82,5.82,0,0,0,2-4.73c0-2-.6-3.44-1.86-4.26s-3.18-1.25-5.8-1.27l-4.58,0Z"/><path class="cls-2" d="M404.19,15.54,36.5,0a22,22,0,0,0-22.9,21L0,342.8a22,22,0,0,0,21,22.89l367.7,15.53a22,22,0,0,0,22.89-21L425.23,38.44A22,22,0,0,0,404.19,15.54ZM380.74,352.35l-341,.32a12,12,0,0,1-12-12l-.28-298a12,12,0,0,1,12-12l341-.32a12,12,0,0,1,12,12l.28,298A12,12,0,0,1,380.74,352.35Z"/><rect class="cls-2" x="67.07" y="120.66" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="142.2" y="121.64" width="57.99" height="59.93" rx="12"/><rect class="cls-1" x="217.38" y="121.48" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="292.51" y="122.47" width="57.99" height="59.93" rx="12"/><rect class="cls-1" x="68.41" y="193.7" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="143.54" y="194.68" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="218.73" y="194.52" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="293.85" y="195.5" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="140.91" y="269.86" width="57.99" height="59.93" rx="12"/><rect class="cls-2" x="216.09" y="269.7" width="57.99" height="59.93" rx="12"/><rect class="cls-1" x="291.22" y="270.68" width="57.99" height="59.93" rx="12"/></g></g></svg> diff --git a/app/static/images/notes/notes.jfif b/app/static/images/notes/notes.jfif new file mode 100644 index 00000000..d876c78a Binary files /dev/null and b/app/static/images/notes/notes.jfif differ diff --git a/app/static/images/worldwide.png b/app/static/images/worldwide.png new file mode 100644 index 00000000..c92d716d Binary files /dev/null and b/app/static/images/worldwide.png differ diff --git a/app/static/joke.js b/app/static/joke.js new file mode 100644 index 00000000..4cfd223b --- /dev/null +++ b/app/static/joke.js @@ -0,0 +1,14 @@ +function makejoke() { + fetch('/joke') + .then(response => response.json()) + .then(data => Swal.fire(data.text)); +} + + +function addEventsAfterPageLoaded() { + const element = document.getElementById("a-joke"); + element.addEventListener("click", makejoke, false); +} + + +document.addEventListener("DOMContentLoaded", addEventsAfterPageLoaded); \ No newline at end of file diff --git a/app/static/js/categories_filter.js b/app/static/js/categories_filter.js new file mode 100644 index 00000000..14e940fa --- /dev/null +++ b/app/static/js/categories_filter.js @@ -0,0 +1,32 @@ +document.addEventListener("DOMContentLoaded", function () { + document.getElementById("category-button").addEventListener("click", function () { + filterByCategory(); + }); +}); + +function filterByCategory(){ + // TODO(issue#67): Allow filter by category name + const category = document.getElementById("category").value; + + const allEvents = document.getElementsByClassName("event_line"); + for (event of allEvents) { + if (event.dataset.name == category) + { + event.dataset.value = "visible"; + } + else { + event.dataset.value = "hidden"; + } + if (!Number.isInteger(+category) || !category || 0 === category.length) { + event.dataset.value = "visible"; + } + event.parentNode.dataset.value = "hidden"; + } + + // Set wrapper div to display "visible" if at least one child is visible. + for (event of allEvents) { + if (event.dataset.value === "visible") { + event.parentNode.dataset.value = "visible"; + } + } +} \ No newline at end of file diff --git a/app/static/js/darkmode.js b/app/static/js/darkmode.js new file mode 100644 index 00000000..0e4eeb7b --- /dev/null +++ b/app/static/js/darkmode.js @@ -0,0 +1,29 @@ +const ROOT = document.documentElement; + +window.addEventListener("DOMContentLoaded", (event) => { + const button = document.getElementById("darkmode"); + let isDarkMode = localStorage.getItem("isDarkMode") == "true"; + setThemeMode(isDarkMode, button, ROOT); + button.addEventListener("click", (event) => { + isDarkMode = !isDarkMode; + localStorage.setItem("isDarkMode", isDarkMode); + setThemeMode(isDarkMode, button, ROOT); + }); +}); + +function changeIcon(mode) { + const modeButton = document.getElementById("darkmode"); + modeButton.name = mode; +} + +function setThemeMode(isDarkMode, button, root) { + if (isDarkMode) { + root.dataset['colorMode'] = "dark"; + button.name = "moon"; + changeIcon("moon"); + } else { + root.dataset['colorMode'] = "regular"; + button.name = "moon-outline"; + changeIcon("moon-outline"); + } +} diff --git a/app/static/js/dates_calculator.js b/app/static/js/dates_calculator.js new file mode 100644 index 00000000..12754fd4 --- /dev/null +++ b/app/static/js/dates_calculator.js @@ -0,0 +1,21 @@ +window.addEventListener('DOMContentLoaded', (event) => { + document.getElementById("CalcBtn").addEventListener("click", hiddenDifference); +}); + +function hiddenDifference() { + if (document.getElementById("endDate").value == '') { + swal("Expected end date"); + return; + } + let date1 = document.getElementById("startDate").value; + const date2 = new Date(document.getElementById("endDate").value); + if (date1 != '') { + date1 = new Date(date1); + } + else { + date1 = Date.now(); + } + const diffDates = Math.abs(date2 - date1); + const diffInDays = Math.ceil(diffDates / (1000 * 60 * 60 * 24)); + document.getElementById("demo").innerText = "The difference: " + (diffInDays) + " days"; +} diff --git a/app/static/js/graph.js b/app/static/js/graph.js new file mode 100644 index 00000000..b14f881b --- /dev/null +++ b/app/static/js/graph.js @@ -0,0 +1,51 @@ +function busiestDayOfTheWeekGraph(events) { + events = JSON.parse(events); + + const data = Object.values(events); + const labels = Object.keys(events); + const ctx = document.getElementById("myChart"); + ctx.style.backgroundColor = "rgba(255, 255, 255, 1)"; + const myChart = new Chart(ctx, { + type: "bar", + data: { + labels: labels, + datasets: [{ + label: "# Events", + data: data, + backgroundColor: [ + "rgba(255, 99, 132, 0.2)", + "rgba(54, 162, 235, 0.2)", + "rgba(255, 206, 86, 0.2)", + "rgba(75, 192, 192, 0.2)", + "rgba(153, 102, 255, 0.2)", + "rgba(255, 159, 64, 0.2)", + "rgba(200, 130, 40, 0.2)", + "rgba(255, 99, 132, 0.2)" + ], + borderColor: [ + "rgba(255, 99, 132, 1)", + "rgba(54, 162, 235, 1)", + "rgba(255, 206, 86, 1)", + "rgba(75, 192, 192, 1)", + "rgba(153, 102, 255, 1)", + "rgba(255, 159, 64, 1)", + "rgba(200, 130, 64, 1)", + "rgba(255, 99, 132, 1)" + ], + borderWidth: 1 + }] + } + }); +} + +function addEventsAfterPageLoaded() { + const element = document.getElementsByClassName("graph")[0]; + if (element) { + element.addEventListener("click", function () { + let eventsPerDateData = element.name; + busiestDayOfTheWeekGraph(eventsPerDateData); + }, false); + } +} + +document.addEventListener("DOMContentLoaded", addEventsAfterPageLoaded); \ No newline at end of file diff --git a/app/static/js/settings.js b/app/static/js/settings.js new file mode 100644 index 00000000..77ee2d13 --- /dev/null +++ b/app/static/js/settings.js @@ -0,0 +1,23 @@ +document.addEventListener('DOMContentLoaded', () => { + const tabBtn = document.getElementsByClassName("tab"); + for (let i = 0; i < tabBtn.length; i++) { + const btn = document.getElementById("tab" + i); + btn.addEventListener('click', () => { + tabClick(btn.id, tabBtn); + }); + } +}); + + +function tabClick(tab_id, tabBtn) { + let shownTab = document.querySelector(".tab-show"); + let selectedTabContent = document.querySelector(`#${tab_id}-content`); + shownTab.classList.remove("tab-show"); + shownTab.classList.add("tab-hide"); + for (btn of tabBtn) { + btn.children[0].classList.remove("active"); + } + document.getElementById(tab_id).classList.add("active"); + selectedTabContent.classList.remove("tab-hide"); + selectedTabContent.classList.add("tab-show"); +} diff --git a/app/static/js/shared_list.js b/app/static/js/shared_list.js new file mode 100644 index 00000000..eab047e1 --- /dev/null +++ b/app/static/js/shared_list.js @@ -0,0 +1,31 @@ +window.addEventListener('load', () => { + document.getElementById("btn-add-item").addEventListener('click', addItem); +}); + + +function addItem() { + const LIST_ITEMS_NUM = document.querySelectorAll("#items > div").length; + const list_items = document.getElementById("items"); + let shared_list_item = document.getElementById("shared-list-item").cloneNode(true); + + shared_list_item.className = "shared-list-item-on"; + shared_list_item.id = shared_list_item.id + LIST_ITEMS_NUM; + for (child of shared_list_item.children) { + if (child.tagName == 'INPUT') { + child.setAttribute('required', 'required'); + } + } + list_items.appendChild(shared_list_item); + document.querySelector(`#${shared_list_item.id} > .remove-btn`).addEventListener('click', () => { + removeItem(shared_list_item, list_items); + }) +} + + +function removeItem(shared_list_item, list_items) { + shared_list_item.remove(); + for (const [index, child] of list_items.childNodes.entries()) + { + child.id = "shared-list-item" + String(index); + } +} diff --git a/app/static/js/todo_list_modals.js b/app/static/js/todo_list_modals.js new file mode 100644 index 00000000..33938922 --- /dev/null +++ b/app/static/js/todo_list_modals.js @@ -0,0 +1,32 @@ +window.addEventListener('DOMContentLoaded', (event) => { + document.getElementsByName('open-edit-btn').forEach(function(entry) { + entry.addEventListener('click', openEditModal); + }); + + document.getElementById('edit-modal-delete').addEventListener( + 'click', deleteModal); +}); + +function openEditModal(event){ + const modal = document.getElementById('edit-modal'); + const button = event.target; + const taskId = button.getAttribute('data-bs-task-id'); + const taskTime = button.getAttribute('data-bs-time'); + const taskTitle = button.getAttribute('data-bs-title'); + const taskDescription = button.getAttribute('data-bs-description'); + const taskImportant = button.getAttribute('data-bs-important') == "true"; + + + document.getElementById('edit-task-id').value = taskId; + document.getElementById('customer-time2').value = taskTime; + document.getElementById('customer-title2').value = taskTitle; + document.getElementById('customer-descrip2').value = taskDescription; + document.getElementById('is-important2').checked = taskImportant; +} + + +function deleteModal() { + const taskId = document.getElementById('edit-task-id').value; + document.getElementById('delete-task-id').value = taskId; + document.getElementById('delete-task-form').submit(); +} \ No newline at end of file diff --git a/app/static/js/view_toggle.js b/app/static/js/view_toggle.js new file mode 100644 index 00000000..602b6451 --- /dev/null +++ b/app/static/js/view_toggle.js @@ -0,0 +1,65 @@ +function refreshview() { + location.reload(); +} + +function close_the_view() { + dayviewdiv = document.getElementById("day-view"); + dayviewdiv.classList.remove("day-view-class", "day-view-limitions", "day-view-visible"); + dayviewdiv.innerText = ''; +} + +function set_views_styles(view_element, element_id) { + if (element_id == "day-view") { + view_element.classList.add("day-view-class", "day-view-limitions"); + btn = document.getElementById('close-day-view'); + btn.addEventListener("click", function() { + close_the_view(); + }); + } +} + +function change_view(view, day, element_id) { + if (element_id == "calendarview") { + close_the_view(); + } + const path = `/${view}/${day}`; + fetch(path).then(function(response) { + return response.text(); + }).then(function(html) { + view_element = document.getElementById(element_id); + view_element.innerHTML = html; + set_views_styles(view_element, element_id); + }); +} + +function set_toggle_view_btns(btn, view) { + dayview_btn = document.getElementById(btn); + day = dayview_btn.name; + dayview_btn.addEventListener('click', function() { + change_view(view, day, "calendarview"); + }); +} + +function set_days_view_open() { + const Days = document.getElementsByClassName('day'); + for (let i = 0; i < Days.length; i++) { + let day = Days[i].title; + Days[i].addEventListener('click', function() { + change_view('day', day, 'day-view'); + }); + } +} + + +document.addEventListener( + 'DOMContentLoaded', + function() { + set_toggle_view_btns('week-view-button', 'week'); + set_toggle_view_btns('day-view-button', 'day'); + month = document.getElementById('month-view-button'); + month.addEventListener('click', function() { + refreshview(); + }); + set_days_view_open(); + } +) diff --git a/app/static/notes.css b/app/static/notes.css new file mode 100644 index 00000000..3970924c --- /dev/null +++ b/app/static/notes.css @@ -0,0 +1,72 @@ +* { + padding: 0; + margin: 0; + box-sizing: border-box; +} + +body { + background-color: rgb(219, 226, 226); +} + +.row { + background-color: white; + border-radius: 2rem; + box-shadow: 0.75rem 0.75rem 1.375rem gray; +} + +img { + border-top-left-radius: 2rem; + border-bottom-left-radius: 2rem; +} + +.btn1 { + border: none; + outline: none; + height: 3.125em; + width: 100%; + background-color: black; + color: white; + border-radius: 0.25rem; + font-weight: bold; +} + +.btn1:hover { + background-color: white; + color: black; + border: 1px solid; +} + +.table-wrapper { + padding: 1.25rem 1.5rem; + margin: 1rem 0; +} + +.table-title { + color: #fff; + padding-bottom: 1rem; + background: linear-gradient( + 45deg, + rgba(0, 97, 215, 1) 0%, + rgba(0, 200, 255, 1) 100% + ); + border-top-left-radius: 2rem; + border-top-right-radius: 2rem; + padding: 1rem 2rem; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.247); +} + +.table-title .btn-group { + float: right; +} + +.table-title .btn { + color: #fff; + float: right; + min-width: 3em; + margin-left: 1rem; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.247); +} + +table.table tr th:last-child { + width: 3.75em; +} diff --git a/app/static/notification.css b/app/static/notification.css new file mode 100644 index 00000000..7dbe0a7d --- /dev/null +++ b/app/static/notification.css @@ -0,0 +1,70 @@ +/* general */ +#main { + width: 90%; + margin: 0 25% 0 5%; +} + +#link { + font-size: 1.5rem; +} + + +/* notifications */ +#notifications-box { + margin-top: 1rem; + +} + +.notification { + padding: 0.5rem 1rem; + display: flex; + justify-content: space-between; +} + +.notification:hover { + background-color: var(--surface-variant); + border-radius: 0.2rem; +} + +.action, .description { + display: inline-block; +} + +.action { + width: 4rem; +} + + +/* buttons */ +.notification-btn { + background-color: transparent; + border: none; +} + +.notification-btn:focus { + outline: 0; +} + +.btn-accept { + color: green; +} + +.btn-decline { + color: red; +} + +#mark-all-as-read { + margin: 1rem; +} + + +/* form */ +.notification-form { + display: inline-block; +} + + +/* icons */ +.icon { + font-size: 1.5rem; +} diff --git a/app/static/popover.js b/app/static/popover.js deleted file mode 100644 index ed6f9b8a..00000000 --- a/app/static/popover.js +++ /dev/null @@ -1,9 +0,0 @@ -// Enable bootstrap popovers - -var popoverList = popoverTriggerList.map(function (popoverTriggerEl) { - return new bootstrap.Popover(popoverTriggerEl, { - container: 'body', - html: true, - sanitize: false - }) -}); diff --git a/app/static/settings_style.css b/app/static/settings_style.css new file mode 100644 index 00000000..b2eda0b8 --- /dev/null +++ b/app/static/settings_style.css @@ -0,0 +1,136 @@ +/* Settings page */ + +.sub-title { + font-size: 1em; + padding-top: 0.2em; +} + +.settings { + color: #222831; + display: flex; + font-size: 0.9em; +} + +#settings-left { + padding: 1.5em 1.5em; + display: flex; + flex-direction: column; + background-color: rgb(230, 230, 230); +} + +.settings-layout { + display: flex; +} + +.left-options-bar { + display: flex; +} + +.settings-main article { + margin-bottom: 2em; +} + +.settings-options { + flex: 1; +} + +.settings-options ul { + list-style-type: none; + padding-left: 0; + padding-right: 1em; +} + +.settings-options ul li { + margin-bottom: 1em; + font-weight: bold; +} + +.settings-options ul > li > a:hover, +.settings-options ul li a.active { + transition: all .3s ease-in-out; + color: #5786f5; + cursor: pointer; + text-decoration: none; +} + +.tab-show { + display: block; + transition: all .5s ease-in; +} + +.tab-hide { + display: none; +} + +.settings-main { + flex: 3; + padding: 1.5em 1.5em; +} + +.settings-main h2 { + margin-bottom: 1em; +} + +.settings-main .form-select { + width: 17em; +} + +.tab-show p { + display: block; + align-self: left; +} + +.tab-show h2 { + display: block; + align-self: left; + font-weight: bold; +} + +.form-select { + font-size: 0.8em; +} + +.form-label { + margin: 1em 0em; +} + +/* For Mobile */ +@media screen and (max-width: 600px) { + .settings { + display: block; + font-size: 3vw; + } + + #settings-left { + padding: 1.5em 0em 0em 2em; + width: 100%; + } + + .settings-options { + margin-bottom: 1em; + } + + .settings-options ul { + padding-left: 0em; + } + + .settings-options ul li { + margin-bottom: 0.5em; + font-weight: bold; + } + + .settings-main { + padding: 2em 2em; + } + .settings-main .form-select { + width: 17em; + } + + .settings-main h2 { + margin-bottom: 1em; + } + + .settings-main p { + margin: 1em 0em; + } +} diff --git a/app/static/style.css b/app/static/style.css index c9dd9f26..170d9999 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,3 +1,19 @@ +:root[data-color-mode="regular"] { + --backgroundcol: #F7F7F7; + --textcolor: #222831; + --navcolor: rgba(0, 0, 0, 0.55); + --navhovercolor: rgba(0, 0, 0, 0.7); + --cardcolor: #FFF; +} + +:root[data-color-mode="dark"] { + --backgroundcol: #000000; + --textcolor: #EEEEEE; + --navcolor: #E9ECEF; + --navhovercolor: rgb(255 255 255); + --cardcolor: #230A88; +} + .profile-image { width: 7em; } @@ -88,7 +104,12 @@ p { margin: 0; } +.card { + background-color: var(--cardcolor); +} + .card-body { + color: var(--textcolor); overflow: auto; } @@ -97,20 +118,106 @@ p { background-color: whitesmoke; } +#on, +#off { + background: rgb(197, 204, 197); + border: 0.2em solid black; + border-radius: 0.5em; + padding: 1em; + margin: 1.5em; + color: rgb(20, 20, 20); + font-size: 1em; + transition-duration: 0.1s; + text-decoration: none; + cursor: pointer; + background-color: whitesmoke; +} + +#activate { + margin-bottom: 1em; +} + +#rangeInput-sfx, +#rangeInput-music { + display: block; +} + +.audio-section { + margin-bottom: 1em; +} + +.error-message { + line-height: 0; + color: red; + padding-left: 12.5rem; +} + +.subtitle { + font-size: 1.25rem; +} + .error-message { - line-height: 0; - color: red; - padding-left: 12.5rem; + line-height: 0; + color: red; + padding-left: 12.5rem; + margin-bottom: 1em; +} + +.forgot-password { + line-height: 0; + color: rgb(188, 7, 194); + padding-left: 8rem; +} + +.red-massage { + color: red; } .input-upload-file { - margin-top: 1em; + margin-top: 1em; +} + +.relative.overflow-hidden { + background-color: var(--backgroundcol); +} + +.navbar-light .navbar-nav .nav-link { + color: var(--navcolor); +} + +.navbar-light .navbar-nav .nav-link:hover { + color: var(--navhovercolor); +} + +.main-text-color { + color: var(--textcolor); +} + +.cal-img { + text-align: center; +} + +#darkmode { + cursor: pointer; } .upload-file { - margin: auto 1em auto 0em; + margin: auto 1em auto 0em; } h2.modal-title { font-size: 1.25rem; } + +#sfx { + width: 5rem; + height: 2.5rem; + margin-top: 1rem; +} + +.reset-password{ + font-size: 2rem; + font-style: bold; + margin: 2rem; + color:black; +} diff --git a/app/static/tracks/GASTRONOMICA.mp3 b/app/static/tracks/GASTRONOMICA.mp3 new file mode 100644 index 00000000..8e0b7a78 Binary files /dev/null and b/app/static/tracks/GASTRONOMICA.mp3 differ diff --git a/app/static/tracks/PHARMACOKINETICS.mp3 b/app/static/tracks/PHARMACOKINETICS.mp3 new file mode 100644 index 00000000..d6b0c293 Binary files /dev/null and b/app/static/tracks/PHARMACOKINETICS.mp3 differ diff --git a/app/static/tracks/SQUEEK!.mp3 b/app/static/tracks/SQUEEK!.mp3 new file mode 100644 index 00000000..1315a3a8 Binary files /dev/null and b/app/static/tracks/SQUEEK!.mp3 differ diff --git a/app/static/tracks/click_1.wav b/app/static/tracks/click_1.wav new file mode 100644 index 00000000..cd5ac256 Binary files /dev/null and b/app/static/tracks/click_1.wav differ diff --git a/app/static/tracks/click_2.wav b/app/static/tracks/click_2.wav new file mode 100644 index 00000000..4f13724f Binary files /dev/null and b/app/static/tracks/click_2.wav differ diff --git a/app/static/tracks/click_3.wav b/app/static/tracks/click_3.wav new file mode 100644 index 00000000..45aa32c6 Binary files /dev/null and b/app/static/tracks/click_3.wav differ diff --git a/app/static/tracks/click_4.wav b/app/static/tracks/click_4.wav new file mode 100644 index 00000000..bc6f591a Binary files /dev/null and b/app/static/tracks/click_4.wav differ diff --git a/app/static/tracks/credits_and_licenses.txt b/app/static/tracks/credits_and_licenses.txt new file mode 100644 index 00000000..61519c80 --- /dev/null +++ b/app/static/tracks/credits_and_licenses.txt @@ -0,0 +1,8 @@ +-Music- +xnobis(New Colonies): +http://www.newcolonies.com/ +license: https://creativecommons.org/licenses/by-nc-sa/3.0/ + + +-Sound effects- +https://freesound.org/ \ No newline at end of file diff --git a/app/static/weekview.css b/app/static/weekview.css index 99743ec6..30ff3e9c 100644 --- a/app/static/weekview.css +++ b/app/static/weekview.css @@ -1,13 +1,5 @@ -:root { - --primary:#30465D; - --primary-variant:#FFDE4D; - --secondary:#EF5454; - --borders:#E7E7E7; - --borders-variant:#F7F7F7; -} - .day-weekview { - border-left: 1px solid var(--borders); + border: 1px solid var(--borders); width: 100%; } @@ -15,8 +7,14 @@ display: grid; grid-template-rows: 1fr; grid-template-columns: 2.3em 1fr; + overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; } +#week-view::-webkit-scrollbar { + display: none; +} #week-schedule { grid-row: 1; @@ -39,3 +37,7 @@ margin-left: -2; overflow: hidden; } + +#all_day_event_in_week { + color: #EF5454; +} diff --git a/app/static/weight.css b/app/static/weight.css new file mode 100644 index 00000000..5704115a --- /dev/null +++ b/app/static/weight.css @@ -0,0 +1,13 @@ + +.weight { + display: block; + margin-top: 1rem; + margin-left: 1rem; + } + + +.weight-message { + display: block; + margin-top: 1rem; + margin-left: 1rem; + } diff --git a/app/telegram/bot.py b/app/telegram/bot.py index 3bfe15ef..83fc0517 100644 --- a/app/telegram/bot.py +++ b/app/telegram/bot.py @@ -2,6 +2,7 @@ from app import config from app.dependencies import get_settings + from .models import Bot settings: config.Settings = get_settings() diff --git a/app/telegram/handlers.py b/app/telegram/handlers.py index 8b2a6719..d6619c93 100644 --- a/app/telegram/handlers.py +++ b/app/telegram/handlers.py @@ -6,10 +6,16 @@ from app.database.models import User from app.dependencies import get_db from app.routers.event import create_event + from .bot import telegram_bot from .keyboards import ( - DATE_FORMAT, field_kb, gen_inline_keyboard, - get_this_week_buttons, new_event_kb, show_events_kb) + DATE_FORMAT, + field_kb, + gen_inline_keyboard, + get_this_week_buttons, + new_event_kb, + show_events_kb, +) from .models import Chat @@ -18,21 +24,22 @@ def __init__(self, chat: Chat, user: User): self.chat = chat self.user = user self.handlers = {} - self.handlers['/start'] = self.start_handler - self.handlers['/show_events'] = self.show_events_handler - self.handlers['/new_event'] = self.new_event_handler - self.handlers['Today'] = self.today_handler - self.handlers['This week'] = self.this_week_handler + self.handlers["/start"] = self.start_handler + self.handlers["/show_events"] = self.show_events_handler + self.handlers["/new_event"] = self.new_event_handler + self.handlers["Today"] = self.today_handler + self.handlers["This week"] = self.this_week_handler # Add next 6 days to handlers dict for row in get_this_week_buttons(): for button in row: - self.handlers[button['text']] = self.chosen_day_handler + self.handlers[button["text"]] = self.chosen_day_handler async def process_callback(self): if self.chat.user_id in telegram_bot.MEMORY: return await self.process_new_event( - telegram_bot.MEMORY[self.chat.user_id]) + telegram_bot.MEMORY[self.chat.user_id], + ) elif self.chat.message in self.handlers: return await self.handlers[self.chat.message]() return await self.default_handler() @@ -43,118 +50,121 @@ async def default_handler(self): return answer async def start_handler(self): - answer = f'''Hello, {self.chat.first_name}! -Welcome to PyLendar telegram client!''' + answer = f"""Hello, {self.chat.first_name}! +Welcome to PyLendar telegram client!""" await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def show_events_handler(self): - answer = 'Choose events day.' + answer = "Choose events day." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=show_events_kb) + reply_markup=show_events_kb, + ) return answer async def today_handler(self): today = datetime.datetime.today() events = [ - _.events for _ in self.user.events - if _.events.start <= today <= _.events.end] + _.events + for _ in self.user.events + if _.events.start <= today <= _.events.end + ] if not events: return await self._process_no_events_today() answer = f"{today.strftime('%A, %B %d')}:\n" - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) for event in events: await self._send_event(event) return answer async def _process_no_events_today(self): answer = "There're no events today." - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def this_week_handler(self): - answer = 'Choose a day.' + answer = "Choose a day." this_week_kb = gen_inline_keyboard(get_this_week_buttons()) await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=this_week_kb) + reply_markup=this_week_kb, + ) return answer async def chosen_day_handler(self): chosen_date = datetime.datetime.strptime( - self.chat.message, DATE_FORMAT) + self.chat.message, + DATE_FORMAT, + ) events = [ - _.events for _ in self.user.events - if _.events.start <= chosen_date <= _.events.end] + _.events + for _ in self.user.events + if _.events.start <= chosen_date <= _.events.end + ] if not events: return await self._process_no_events_on_date(chosen_date) answer = f"{chosen_date.strftime('%A, %B %d')}:\n" - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) for event in events: await self._send_event(event) return answer async def _process_no_events_on_date(self, date): answer = f"There're no events on {date.strftime('%B %d')}." - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def _send_event(self, event): start = event.start.strftime("%d %b %Y %H:%M") end = event.end.strftime("%d %b %Y %H:%M") - text = f'Title:\n{event.title}\n\n' - text += f'Content:\n{event.content}\n\n' - text += f'Location:\n{event.location}\n\n' - text += f'Starts on:\n{start}\n\n' - text += f'Ends on:\n{end}' - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=text) + text = f"Title:\n{event.title}\n\n" + text += f"Content:\n{event.content}\n\n" + text += f"Location:\n{event.location}\n\n" + text += f"Starts on:\n{start}\n\n" + text += f"Ends on:\n{end}" + await telegram_bot.send_message(chat_id=self.chat.user_id, text=text) await asyncio.sleep(1) async def process_new_event(self, memo_dict): - if self.chat.message == 'cancel': + if self.chat.message == "cancel": return await self._cancel_new_event_processing() - elif self.chat.message == 'restart': + elif self.chat.message == "restart": return await self._restart_new_event_processing() - elif 'title' not in memo_dict: + elif "title" not in memo_dict: return await self._process_title(memo_dict) - elif 'content' not in memo_dict: + elif "content" not in memo_dict: return await self._process_content(memo_dict) - elif 'location' not in memo_dict: + elif "location" not in memo_dict: return await self._process_location(memo_dict) - elif 'start' not in memo_dict: + elif "start" not in memo_dict: return await self._process_start_date(memo_dict) - elif 'end' not in memo_dict: + elif "end" not in memo_dict: return await self._process_end_date(memo_dict) - elif self.chat.message == 'create': + elif self.chat.message == "create": return await self._submit_new_event(memo_dict) async def new_event_handler(self): telegram_bot.MEMORY[self.chat.user_id] = {} - answer = 'Please, give your event a title.' + answer = "Please, give your event a title." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _cancel_new_event_processing(self): del telegram_bot.MEMORY[self.chat.user_id] - answer = '🚫 The process was canceled.' - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + answer = "🚫 The process was canceled." + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def _restart_new_event_processing(self): @@ -162,33 +172,36 @@ async def _restart_new_event_processing(self): return answer async def _process_title(self, memo_dict): - memo_dict['title'] = self.chat.message + memo_dict["title"] = self.chat.message answer = f'Title:\n{memo_dict["title"]}\n\n' - answer += 'Add a description of the event.' + answer += "Add a description of the event." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_content(self, memo_dict): - memo_dict['content'] = self.chat.message + memo_dict["content"] = self.chat.message answer = f'Content:\n{memo_dict["content"]}\n\n' - answer += 'Where the event will be held?' + answer += "Where the event will be held?" await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_location(self, memo_dict): - memo_dict['location'] = self.chat.message + memo_dict["location"] = self.chat.message answer = f'Location:\n{memo_dict["location"]}\n\n' - answer += 'When does it start?' + answer += "When does it start?" await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_start_date(self, memo_dict): @@ -198,21 +211,23 @@ async def _process_start_date(self, memo_dict): return await self._process_bad_date_input() async def _add_start_date(self, memo_dict, date): - memo_dict['start'] = date + memo_dict["start"] = date answer = f'Starts on:\n{date.strftime("%d %b %Y %H:%M")}\n\n' - answer += 'And when does it end?' + answer += "And when does it end?" await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_bad_date_input(self): - answer = '❗️ Please, enter a valid date/time.' + answer = "❗️ Please, enter a valid date/time." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_end_date(self, memo_dict): @@ -222,32 +237,32 @@ async def _process_end_date(self, memo_dict): return await self._process_bad_date_input() async def _add_end_date(self, memo_dict, date): - memo_dict['end'] = date + memo_dict["end"] = date start_time = memo_dict["start"].strftime("%d %b %Y %H:%M") answer = f'Title:\n{memo_dict["title"]}\n\n' answer += f'Content:\n{memo_dict["content"]}\n\n' answer += f'Location:\n{memo_dict["location"]}\n\n' - answer += f'Starts on:\n{start_time}\n\n' + answer += f"Starts on:\n{start_time}\n\n" answer += f'Ends on:\n{date.strftime("%d %b %Y %H:%M")}' await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=new_event_kb) + reply_markup=new_event_kb, + ) return answer async def _submit_new_event(self, memo_dict): - answer = 'New event was successfully created 🎉' - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + answer = "New event was successfully created 🎉" + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) # Save to database create_event( db=next(get_db()), - title=memo_dict['title'], - start=memo_dict['start'], - end=memo_dict['end'], - content=memo_dict['content'], + title=memo_dict["title"], + start=memo_dict["start"], + end=memo_dict["end"], + content=memo_dict["content"], owner_id=self.user.id, - location=memo_dict['location'], + location=memo_dict["location"], ) # Delete current session del telegram_bot.MEMORY[self.chat.user_id] @@ -255,7 +270,7 @@ async def _submit_new_event(self, memo_dict): async def reply_unknown_user(chat): - answer = f''' + answer = f""" Hello, {chat.first_name}! To use PyLendar Bot you have to register @@ -265,6 +280,6 @@ async def reply_unknown_user(chat): Keep it secret! https://calendar.pythonic.guru/profile/ -''' +""" await telegram_bot.send_message(chat_id=chat.user_id, text=answer) return answer diff --git a/app/templates/agenda.html b/app/templates/agenda.html index f86e5a38..42d4ba47 100644 --- a/app/templates/agenda.html +++ b/app/templates/agenda.html @@ -1,38 +1,62 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block head %} {{ super() }} <link href="{{ url_for('static', path='/agenda_style.css') }}" rel="stylesheet"> + <script type="text/javascript" src="{{ url_for('static', path='js/categories_filter.js') }}"></script> {% endblock %} {% block content %} - <form method="GET" action="/agenda#dates" class="mx-3 pt-3"> - <div class="col-sm-3"> - <label for="start_date">{{ gettext("From") }}</label> - <input class="form-control" type="date" id="start_date" name="start_date" value='{{ start_date }}'> + <div class="agenda_grid"> + <div class="agenda_filter_grid"> + <form method="GET" action="/agenda#dates" class="mx-3 pt-3"> + <div class="col-sm-3"> + <label for="start_date">{{ gettext("From") }}</label> + <input class="form-control" type="date" id="start_date" name="start_date" value='{{ start_date }}'> + </div> + <div class="col-sm-3"> + <label for="end_date">{{ gettext("To") }}</label> + <input class="form-control" type="date" id="end_date" name="end_date" value='{{ end_date }}'><br> + </div> + <div> + <input class="btn btn-primary btn-sm" type="submit" value="{{ gettext('Get Agenda') }}"> + </div> + </form> </div> - <div class="col-sm-3"> - <label for="end_date">{{ gettext("To") }}</label> - <input class="form-control" type="date" id="end_date" name="end_date" value='{{ end_date }}'><br> - </div> - <div> - <input class="btn btn-primary btn-sm" type="submit" value="{{ gettext('Get Agenda') }}"> + + <div class="category_filter"> + <label for="category" class="sr-only">Filter events by category</label> + <input type="text" id="category" name="category" class="form-control-sm" + placeholder="category ID" value="{{ category }}" + onfocus="this.value=''" required> + <button class="btn btn-outline-info btn-sm" id="category-button"> + Filter by Categories + </button> </div> - </form> - <div class="exact_date pt-3"> - <div class="mx-3"> - <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=0#dates">{{ gettext("Today") }}</a> + <div class="exact_date pt-3"> + <div class="mx-3"> + <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=0#dates">{{ gettext("Today") }}</a> + </div> + <div class="mx-3"> + <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=7#dates">{{ gettext("Next 7 days") }}</a> + </div> + <div class="mx-3"> + <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=30#dates">{{ gettext("Next 30 days") }}</a> + </div> </div> <div class="mx-3"> - <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=7#dates">{{ gettext("Next 7 days") }}</a> + <button class="btn btn-light btn-outline-primary btn-sm graph" type="button" name="{{events_for_graph}}">{{ gettext("Week graph") }}</button> </div> - <div class="mx-3"> - <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=30#dates">{{ gettext("Next 30 days") }}</a> + </div> + + <div class="col-sm-4 col-4 card-body"> + <canvas id="myChart" width="400" height="400"></canvas> + <div class="container" id="graph"> </div> </div> - <div class="pt-4 px-5"> + <div class="pt-4 px-5 main-text-color"> {% if start_date > end_date %} <p>{{ gettext("Start date is greater than end date") }}</p> {% elif events | length == 0 %} @@ -44,19 +68,20 @@ <h1 id="dates">{{ start_date.strftime("%d/%m/%Y") }} - {{ end_date.strftime("%d/ {% endif %} </div> - <div> + <div id="events"> {% for events_date, events_list in events.items() %} - - <div class="p-3">{{ events_date.strftime("%d/%m/%Y") }}</div> - {% for event in events_list %} - <div class="event_line" style="background-color: {{ event[0].color }}"> - {% set availability = 'Busy' if event[0].availability == True else 'Free' %} - <div class="{{ availability | lower }}" title="{{ availability }}"></div> - <div><b>{{ event[0].start.time().strftime("%H:%M") }} - <a class="event-title" href="/event/{{ event[0].id }}">{{ event[0].title | safe }}</a></b><br> - <span class="duration">duration: {{ event[1] }}</span> - </div> + <div class="wrapper"> + <div class="p-3">{{ events_date }}</div> + {% for event in events_list %} + <div class="event_line" style="background-color: {{ event.color }}" data-name="{{event.category_id}}"> + {% set availability = 'Busy' if event.availability else 'Free' %} + <div class="{{ availability | lower }}" title="{{ availability }}"></div> + <div><b>{{ event.start }} - <a class="event-title" href="/event/{{ event.id }}">{{ event.title | safe }}</a></b><br> + <span class="duration">duration: {{ event.duration }}</span> + </div> + </div> + {% endfor %} </div> - {% endfor %} {% endfor %} </div> diff --git a/app/templates/archive.html b/app/templates/archive.html new file mode 100644 index 00000000..a74da791 --- /dev/null +++ b/app/templates/archive.html @@ -0,0 +1,26 @@ +{% extends "./partials/notification/base.html" %} +{% block page_name %}Archive{% endblock page_name %} + +{% block description %} + <div> + <div class="title">Archived Notifications</div> + <p class="s-paragraph"> + In this page you can view all of your archived notifications.<br/> + Any notification you have <b>marked as read</b> or <b>declined</b>, you will see here.<br/> + You can use the <button class="notification-btn btn-accept"><ion-icon class="icon" name="checkmark-outline"></ion-icon></button> + button to accept an invitation that you already declined. + </p> + </div> +{% endblock description %} + +{% block link %} + <div id="link"><a href="{{ url_for('view_notifications') }}">New notifications</a></div> +{% endblock link %} + +{% block notifications %} + {% include './partials/notification/generate_archive.html' %} +{% endblock notifications %} + +{% block no_notifications_msg %} + <span>You don't have any archived notifications.</span> +{% endblock no_notifications_msg %} diff --git a/app/templates/audio_settings.html b/app/templates/audio_settings.html new file mode 100644 index 00000000..ef603d9a --- /dev/null +++ b/app/templates/audio_settings.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} {% block content %} +<div class="container mt-4"> + <h1>Audio Settings</h1> + <form action="{{ url_for('get_choices') }}" method="post" id="form1"> + <div class="form-outline audio-section"> + <label>Music:</label> + <input type="radio" id="music-on" name="music_on" value="True" class="form-check-input"> + <label for="music-on" class="form-check-label">On</label> + <input type="radio" id="music-off" name="music_on" value="False" checked class="form-check-input"> + <label for="music-off" class="form-check-label">Off</label> + </div> + <label for="music">Select music tracks:</label> {% for song in songs %} + <div class="form-outline audio-section"> + <input type="checkbox" class="music form-check-input" name="music_choices" value="{{song.name}}.mp3" disabled> + <label for="music" class="form-check-label">{{song.name}}</label> {% endfor %} + </div> + <label>Volume</label> + <input id="rangeInput-music" class="audio-section" type="range" min="0" max="100" name="music_vol" disabled> + <div class="form-outline audio-section"> + <label>Sound Effects:</label> + <input type="radio" id="sound-effects-on" name="sfx_on" value="True" class="form-check-input"> + <label for="sound-effects-on" class="form-check-label">On</label> + <input type="radio" id="sound-effects-off" name="sfx_on" value="False" checked class="form-check-input"> + <label for="sound-effects-off" class="form-check-label">Off</label> + </div> + <label for="sound-effect">Select sound effect:</label> + <select name="sfx_choice" id="sfx" disabled class="form-control audio-section"> + {% for sfx in sound_effects %} + <option value="{{sfx.name}}.wav">{{sfx.name}}</option> + {% endfor %} + </select> + <label>Volume</label> + <input id="rangeInput-sfx" class="audio-section" type="range" min="0" max="100" name="sfx_vol" disabled> + <button id="activate" type="submit" form="form1" class="btn btn-primary audio-section"> + Activate + </button> + </form> +</div> + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index c7431c71..eadfc9c1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,16 +1,15 @@ <!DOCTYPE html> -<html lang="en"> +<html lang="en" data-color-mode="regular"> <head> {% block head %} <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous"> + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" + integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous"> <link rel="icon" href="{{ url_for('static', path='/images/icons/calendar-outline.svg') }}" /> <link href="{{ url_for('static', path='/style.css') }}" rel="stylesheet"> - <script type="module" src="https://unpkg.com/ionicons@5.4.0/dist/ionicons/ionicons.esm.js"></script> - <script nomodule="" src="https://unpkg.com/ionicons@5.4.0/dist/ionicons/ionicons.js"></script> <title>PyLendar {% block title %}{% endblock %} {% endblock %} @@ -20,8 +19,9 @@
- @@ -68,13 +93,30 @@ {% block content %}{% endblock %}
- - - - + + + - + + + + + + + + + + + \ No newline at end of file diff --git a/app/templates/calendar/add_week.html b/app/templates/calendar/add_week.html deleted file mode 100644 index 702ee572..00000000 --- a/app/templates/calendar/add_week.html +++ /dev/null @@ -1,21 +0,0 @@ -{% for week in weeks_block %} -
- {% for day in week.days %} -
-
-
{{day}}
- -
- {% for devent in day.dailyevents %} -
-
{{devent[0]}}
-
{{devent[1]}}
-
- {% endfor %} - {% for event in day.events %} -
{{event[0]}} {{event[1]}}
- {% endfor %} -
- {% endfor %} -
-{% endfor %} \ No newline at end of file diff --git a/app/templates/calendar/calendar.html b/app/templates/calendar/calendar.html deleted file mode 100644 index d5d95c15..00000000 --- a/app/templates/calendar/calendar.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'calendar/layout.html' %} - -{% block main %} -
-
- {% for d in week_days %} - {% if d == day.sday %} -
{{ d.upper() }}
- {% else %} -
{{ d.upper() }}
- {% endif %} - {% endfor %} -
-
- {% include 'calendar/add_week.html' %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/app/templates/calendar/layout.html b/app/templates/calendar/layout.html index 90a320ec..28671c84 100644 --- a/app/templates/calendar/layout.html +++ b/app/templates/calendar/layout.html @@ -7,14 +7,12 @@ - + - - - Calendar @@ -24,7 +22,7 @@ + + + + + + \ No newline at end of file diff --git a/app/templates/calendar_day_view.html b/app/templates/calendar_day_view.html index 745864de..272e3558 100644 --- a/app/templates/calendar_day_view.html +++ b/app/templates/calendar_day_view.html @@ -1,10 +1,15 @@ {% extends "partials/calendar/calendar_base.html" %} {% block body %} -
+{% if view != 'week' %} + {% set scrollclass = "day-view-scrollbar" %} +{% else %} + {% set idmodify = day %} +{% endif %} +
{% if view == 'day' %} - {{month}} @@ -14,58 +19,97 @@ zodiac sign +
+ {% endif %} + {% else %} + {{day}} / {{month}} + {% endif %}
- {% endif %} - {% else %} - {{day}} / {{month}} - {% endif %} -
-
-
- {% for hour in range(25)%} -
-
- {% if view == 'day'%} - {% set hour = hour|string() %} - {{hour.zfill(2)}}:00 - {% endif %} -
-
-
+
+ {% for event in all_day_events %} + {{ event.title }}    {% endfor %} +
+ {% if international_day %} +
+ The International days are: "{{ international_day.international_day }}"
-
- {% for event, attr in events %} -
-
-

{{ event.title }}

- {% if attr.total_time_visible %} -

{{attr.total_time}}

+ {% endif %} + + + + {% include 'partials/todo_list_modals/create_task.html' %} + {% include 'partials/todo_list_modals/todo_list.html' %} + {% include 'partials/todo_list_modals/edit_task.html' %} +
+
+ {% for hour in range(25)%} +
+
+ {% if view == 'day'%} + {% set hour = hour|string() %} + {{hour.zfill(2)}}:00 + {% endif %} +
+
+
+ {% endfor %} +
+
+ {% if current_time.is_viewed %} +
+
+
+
{% endif %}
-
- - - +
+ {% for event, attr in events_and_attrs %} +
+
+

{{ + event.title }}

+ {% if attr.total_time_visible %} +

+ {{attr.total_time}}

+ {% endif %} +
+
+ + + +
+
+ {% endfor %}
-
- {% endfor %}
-
- {% if view == 'day'%} - - {% endif %} + {% if view == 'day'%} + + {% endif %}
-{% if view == 'day'%}
{% endif %} +{% if view == 'day'%} +
{% endif %} - -{% endblock body %} \ No newline at end of file + + +{% endblock body %} diff --git a/app/templates/calendar_monthly_view.html b/app/templates/calendar_monthly_view.html index 5a1d3cf2..b97015a2 100644 --- a/app/templates/calendar_monthly_view.html +++ b/app/templates/calendar_monthly_view.html @@ -1,17 +1,41 @@ {% extends "partials/calendar/calendar_base.html" %} {% block content %} -
+
-
{{ day.display() }}
-
Location 0oc 00:00
+
{{ day.display() }}
+
Location 0oc 00:00
+
+ + + + + + +
+ +
{% include 'partials/calendar/monthly_view/monthly_grid.html' %}
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/app/templates/categories.html b/app/templates/categories.html new file mode 100644 index 00000000..8bf15440 --- /dev/null +++ b/app/templates/categories.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block head %} +{{ super() }} + +{% endblock %} + +{% block content %} +
+

It's time to make some decisions

+

+ Here you can create your unique categories and choose your favorite color +

+ +
+ +

+ +

+

+
+
+{% if message %} +
+

{{ message }}

+
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/celebrity.html b/app/templates/celebrity.html index e549f2ab..6e193e30 100644 --- a/app/templates/celebrity.html +++ b/app/templates/celebrity.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block content %} diff --git a/app/templates/corona_stats.html b/app/templates/corona_stats.html new file mode 100644 index 00000000..69834afd --- /dev/null +++ b/app/templates/corona_stats.html @@ -0,0 +1,16 @@ + +{% if corona_stats_data.get("error") is none %} +
+
+
+

+ + COVID 19 Vaccinated

+

+ {{ corona_stats_data["vaccinated_second_dose_perc"] }}% + ({{ corona_stats_data["vaccinated_second_dose_total"] }}) +

+
+
+
+{% endif %} diff --git a/app/templates/credits.html b/app/templates/credits.html index 04d714dc..0ccda3b5 100644 --- a/app/templates/credits.html +++ b/app/templates/credits.html @@ -2,22 +2,42 @@ {% block head %} {{ super() }} - + {% endblock %} {% block content %} -

Say hello to our developers:

- {% for credit in credit_list %} -