commit 668d9c7df8f3426286abff044c30dc519a43673e Author: giles Date: Mon Feb 9 23:11:36 2026 +0000 feat: initial shared library extraction Contains shared infrastructure for all coop services: - shared/ (factory, urls, user_loader, context, internal_api, jinja_setup) - models/ (User, Order, Calendar, Ticket, Product, Ghost CMS) - db/ (SQLAlchemy async session, base) - suma_browser/app/ (csrf, middleware, errors, authz, redis_cacher, payments, filters, utils) - suma_browser/templates/ (shared base layouts, macros, error pages) - static/ (CSS, JS, fonts, images) - alembic/ (database migrations) - config/ (app-config.yaml) - editor/ (Lexical editor Node.js build) - requirements.txt Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e50efe --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__ +venv +*.pyc +_config +_snapshot +project.zip +.env +_debug +.venv +.claude +editor/node_modules +static/scripts/editor.js +static/scripts/editor.css +suma_browser/static/scripts/editor.js +suma_browser/static/scripts/editor.css + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3c0a67 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# coop/shared + +Shared library for the Rose Ash Cooperative platform. + +Used as a git submodule by each app repo (blog, market, cart, events). diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..29f8e78 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c18764b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +from __future__ import annotations +import os, sys +from logging.config import fileConfig +from alembic import context +from sqlalchemy import engine_from_config, pool + +config = context.config + +if config.config_file_name is not None: + try: + fileConfig(config.config_file_name) + except Exception: + pass + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from db.base import Base +import models # noqa: F401 + +target_metadata = Base.metadata + +def _get_url() -> str: + url = os.getenv( + "ALEMBIC_DATABASE_URL", + os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url") or "") + ) + print(url) + return url + +def run_migrations_offline() -> None: + url = _get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + url = _get_url() + if url: + config.set_main_option("sqlalchemy.url", url) + + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/old_versions/0251106_163500_products_tables.py b/alembic/old_versions/0251106_163500_products_tables.py new file mode 100644 index 0000000..c8ed102 --- /dev/null +++ b/alembic/old_versions/0251106_163500_products_tables.py @@ -0,0 +1,241 @@ +"""snapshot writes to postgres (products/nav/listings/reports) + +Revision ID: 20251107_090500_snapshot_to_db +Revises: 20251106_152905_calendar_config +Create Date: 2025-11-07T09:05:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20251107_090500_snapshot_to_db" +down_revision = "20251106_152905_calendar_config" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # products (if not already present in your DB — keep idempotent with if_exists checks in env if needed) + if not op.get_bind().dialect.has_table(op.get_bind(), "products"): + op.create_table( + "products", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("slug", sa.String(length=255), nullable=False, unique=True), + sa.Column("title", sa.String(length=512), nullable=True), + sa.Column("image", sa.Text(), nullable=True), + sa.Column("description_short", sa.Text(), nullable=True), + sa.Column("description_html", sa.Text(), nullable=True), + sa.Column("suma_href", sa.Text(), nullable=True), + sa.Column("brand", sa.String(length=255), nullable=True), + sa.Column("rrp", sa.Numeric(12, 2), nullable=True), + sa.Column("rrp_currency", sa.String(length=16), nullable=True), + sa.Column("rrp_raw", sa.String(length=128), nullable=True), + sa.Column("price_per_unit", sa.Numeric(12, 4), nullable=True), + sa.Column("price_per_unit_currency", sa.String(length=16), nullable=True), + sa.Column("price_per_unit_raw", sa.String(length=128), nullable=True), + sa.Column("special_price", sa.Numeric(12, 2), nullable=True), + sa.Column("special_price_currency", sa.String(length=16), nullable=True), + sa.Column("special_price_raw", sa.String(length=128), nullable=True), + sa.Column("case_size_count", sa.Integer(), nullable=True), + sa.Column("case_size_item_qty", sa.Numeric(12, 3), nullable=True), + sa.Column("case_size_item_unit", sa.String(length=32), nullable=True), + sa.Column("case_size_raw", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_products_slug", "products", ["slug"], unique=False) + + # product_sections + if not op.get_bind().dialect.has_table(op.get_bind(), "product_sections"): + op.create_table( + "product_sections", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("html", sa.Text(), nullable=False), + ) + op.create_index("ix_product_sections_product_id", "product_sections", ["product_id"], unique=False) + op.create_unique_constraint("uq_product_sections_product_title", "product_sections", ["product_id", "title"]) + + # product_images (add kind + adjust unique) + if not op.get_bind().dialect.has_table(op.get_bind(), "product_images"): + op.create_table( + "product_images", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("url", sa.Text(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column("kind", sa.String(length=16), nullable=False, server_default="gallery"), + sa.CheckConstraint("position >= 0", name="ck_product_images_position_nonneg"), + ) + op.create_index("ix_product_images_product_id", "product_images", ["product_id"], unique=False) + op.create_index("ix_product_images_position", "product_images", ["position"], unique=False) + op.create_unique_constraint("uq_product_images_product_url_kind", "product_images", ["product_id", "url", "kind"]) + else: + # alter existing table to add `kind` and update unique + with op.batch_alter_table("product_images") as batch_op: + if not op.get_bind().dialect.has_column(op.get_bind(), "product_images", "kind"): + batch_op.add_column(sa.Column("kind", sa.String(length=16), nullable=False, server_default="gallery")) + try: + batch_op.drop_constraint("uq_product_images_product_url", type_="unique") + except Exception: + pass + batch_op.create_unique_constraint("uq_product_images_product_url_kind", ["product_id", "url", "kind"]) + + # nav_tops / nav_subs + op.create_table( + "nav_tops", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_nav_tops_slug", "nav_tops", ["slug"], unique=False) + op.create_unique_constraint("uq_nav_tops_label_slug", "nav_tops", ["label", "slug"]) + + op.create_table( + "nav_subs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("top_id", sa.Integer(), sa.ForeignKey("nav_tops.id", ondelete="CASCADE"), nullable=False), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.Column("href", sa.Text(), nullable=True), + ) + op.create_index("ix_nav_subs_top_id", "nav_subs", ["top_id"], unique=False) + op.create_index("ix_nav_subs_slug", "nav_subs", ["slug"], unique=False) + op.create_unique_constraint("uq_nav_subs_top_slug", "nav_subs", ["top_id", "slug"]) + + # listings & listing_items + op.create_table( + "listings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("top_slug", sa.String(length=255), nullable=False), + sa.Column("sub_slug", sa.String(length=255), nullable=True), + sa.Column("total_pages", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_listings_top_slug", "listings", ["top_slug"], unique=False) + op.create_index("ix_listings_sub_slug", "listings", ["sub_slug"], unique=False) + op.create_unique_constraint("uq_listings_top_sub", "listings", ["top_slug", "sub_slug"]) + + op.create_table( + "listing_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("listing_id", sa.Integer(), sa.ForeignKey("listings.id", ondelete="CASCADE"), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=False), + ) + op.create_index("ix_listing_items_listing_id", "listing_items", ["listing_id"], unique=False) + op.create_index("ix_listing_items_slug", "listing_items", ["slug"], unique=False) + op.create_unique_constraint("uq_listing_items_listing_slug", "listing_items", ["listing_id", "slug"]) + + # reports: link_errors, link_externals, subcategory_redirects, product_logs + op.create_table( + "link_errors", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_slug", sa.String(length=255), nullable=True), + sa.Column("href", sa.Text(), nullable=True), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("top", sa.String(length=255), nullable=True), + sa.Column("sub", sa.String(length=255), nullable=True), + sa.Column("target_slug", sa.String(length=255), nullable=True), + sa.Column("type", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_link_errors_product_slug", "link_errors", ["product_slug"], unique=False) + + op.create_table( + "link_externals", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_slug", sa.String(length=255), nullable=True), + sa.Column("href", sa.Text(), nullable=True), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("host", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_link_externals_product_slug", "link_externals", ["product_slug"], unique=False) + + op.create_table( + "subcategory_redirects", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("old_path", sa.String(length=512), nullable=False), + sa.Column("new_path", sa.String(length=512), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_subcategory_redirects_old_path", "subcategory_redirects", ["old_path"], unique=False) + op.create_unique_constraint("uq_subcategory_redirects_old_new", "subcategory_redirects", ["old_path", "new_path"]) + + op.create_table( + "product_logs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("slug", sa.String(length=255), nullable=True), + sa.Column("href_tried", sa.Text(), nullable=True), + sa.Column("ok", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("error_type", sa.String(length=255), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("http_status", sa.Integer(), nullable=True), + sa.Column("final_url", sa.Text(), nullable=True), + sa.Column("transport_error", sa.Boolean(), nullable=True), + sa.Column("title", sa.String(length=512), nullable=True), + sa.Column("has_description_html", sa.Boolean(), nullable=True), + sa.Column("has_description_short", sa.Boolean(), nullable=True), + sa.Column("sections_count", sa.Integer(), nullable=True), + sa.Column("images_count", sa.Integer(), nullable=True), + sa.Column("embedded_images_count", sa.Integer(), nullable=True), + sa.Column("all_images_count", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_product_logs_slug", "product_logs", ["slug"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_product_logs_slug", table_name="product_logs") + op.drop_table("product_logs") + + op.drop_constraint("uq_subcategory_redirects_old_new", "subcategory_redirects", type_="unique") + op.drop_index("ix_subcategory_redirects_old_path", table_name="subcategory_redirects") + op.drop_table("subcategory_redirects") + + op.drop_index("ix_link_externals_product_slug", table_name="link_externals") + op.drop_table("link_externals") + + op.drop_index("ix_link_errors_product_slug", table_name="link_errors") + op.drop_table("link_errors") + + op.drop_index("ix_listing_items_slug", table_name="listing_items") + op.drop_index("ix_listing_items_listing_id", table_name="listing_items") + op.drop_table("listing_items") + + op.drop_constraint("uq_listings_top_sub", "listings", type_="unique") + op.drop_index("ix_listings_sub_slug", table_name="listings") + op.drop_index("ix_listings_top_slug", table_name="listings") + op.drop_table("listings") + + op.drop_constraint("uq_nav_subs_top_slug", "nav_subs", type_="unique") + op.drop_index("ix_nav_subs_slug", table_name="nav_subs") + op.drop_index("ix_nav_subs_top_id", table_name="nav_subs") + op.drop_table("nav_subs") + + op.drop_constraint("uq_nav_tops_label_slug", "nav_tops", type_="unique") + op.drop_index("ix_nav_tops_slug", table_name="nav_tops") + op.drop_table("nav_tops") + + with op.batch_alter_table("product_images") as batch_op: + try: + batch_op.drop_constraint("uq_product_images_product_url_kind", type_="unique") + except Exception: + pass + # Do not drop 'kind' column automatically since existing code may rely on it. + # If needed, uncomment: + # batch_op.drop_column("kind") + op.drop_index("ix_product_images_position", table_name="product_images") + op.drop_index("ix_product_images_product_id", table_name="product_images") + op.drop_table("product_images") + + op.drop_constraint("uq_product_sections_product_title", "product_sections", type_="unique") + op.drop_index("ix_product_sections_product_id", table_name="product_sections") + op.drop_table("product_sections") + + op.drop_index("ix_products_slug", table_name="products") + op.drop_table("products") diff --git a/alembic/old_versions/0d767ad92dd7_add_product_likes.py b/alembic/old_versions/0d767ad92dd7_add_product_likes.py new file mode 100644 index 0000000..6de5edc --- /dev/null +++ b/alembic/old_versions/0d767ad92dd7_add_product_likes.py @@ -0,0 +1,67 @@ + +# Alembic migration script template + +"""empty message + +Revision ID: 0d767ad92dd7 +Revises: 20251021_add_user_and_magic_link +Create Date: 2025-10-24 23:36:41.985357 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '0d767ad92dd7' +down_revision = '20251021_add_user_and_magic_link' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + "product_likes", + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey( + "users.id", + ondelete="CASCADE", + ), + primary_key=True, + nullable=False, + ), + sa.Column( + "product_slug", + sa.String(length=255), + primary_key=True, + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + # If you want an index to quickly fetch "all likes for this user": + op.create_index( + "ix_product_likes_user_id", + "product_likes", + ["user_id"], + unique=False, + ) + + # If you want an index to quickly fetch "who liked this product": + op.create_index( + "ix_product_likes_product_slug", + "product_likes", + ["product_slug"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_product_likes_product_slug", table_name="product_likes") + op.drop_index("ix_product_likes_user_id", table_name="product_likes") + op.drop_table("product_likes") diff --git a/alembic/old_versions/1a1f1f1fc71c_merge_heads.py b/alembic/old_versions/1a1f1f1fc71c_merge_heads.py new file mode 100644 index 0000000..9972273 --- /dev/null +++ b/alembic/old_versions/1a1f1f1fc71c_merge_heads.py @@ -0,0 +1,24 @@ + +# Alembic migration script template + +"""empty message + +Revision ID: 1a1f1f1fc71c +Revises: 20251107_180000_link_listings_to_nav_ids, 20251107_add_product_id_to_likes +Create Date: 2025-11-07 19:34:18.228002 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '1a1f1f1fc71c' +down_revision = ('20251107_180000_link_listings_to_nav_ids', '20251107_add_product_id_to_likes') +branch_labels = None +depends_on = None + +def upgrade() -> None: + pass + +def downgrade() -> None: + pass diff --git a/alembic/old_versions/20251021211617_create_kv.py b/alembic/old_versions/20251021211617_create_kv.py new file mode 100644 index 0000000..3e5c911 --- /dev/null +++ b/alembic/old_versions/20251021211617_create_kv.py @@ -0,0 +1,20 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20251021211617" +down_revision = None +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + 'kv', + sa.Column('key', sa.String(length=120), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('key') + ) + +def downgrade() -> None: + op.drop_table('kv') diff --git a/alembic/old_versions/20251021_add_user_and_magic_link.py b/alembic/old_versions/20251021_add_user_and_magic_link.py new file mode 100644 index 0000000..f479771 --- /dev/null +++ b/alembic/old_versions/20251021_add_user_and_magic_link.py @@ -0,0 +1,47 @@ +"""add users and magic_links tables + +Revision ID: 20251021_add_user_and_magic_link +Revises: a1b2c3d4e5f6 # <-- REPLACE with your actual head +Create Date: 2025-10-21 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '20251021_add_user_and_magic_link' +down_revision: Union[str, None] = '20251021211617' # <-- REPLACE THIS +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + op.create_table( + 'users', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('email', sa.String(length=255), nullable=False, unique=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_index('ix_users_email', 'users', ['email'], unique=True) + + op.create_table( + 'magic_links', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('token', sa.String(length=128), nullable=False, unique=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('purpose', sa.String(length=32), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('ip', sa.String(length=64), nullable=True), + sa.Column('user_agent', sa.String(length=256), nullable=True), + ) + op.create_index('ix_magic_links_token', 'magic_links', ['token'], unique=True) + op.create_index('ix_magic_links_user', 'magic_links', ['user_id']) + +def downgrade() -> None: + op.drop_index('ix_magic_links_user', table_name='magic_links') + op.drop_index('ix_magic_links_token', table_name='magic_links') + op.drop_table('magic_links') + op.drop_index('ix_users_email', table_name='users') + op.drop_table('users') diff --git a/alembic/old_versions/20251028_ghost_content.py b/alembic/old_versions/20251028_ghost_content.py new file mode 100644 index 0000000..cb73ce0 --- /dev/null +++ b/alembic/old_versions/20251028_ghost_content.py @@ -0,0 +1,135 @@ +"""ghost content mirror (posts/pages/authors/tags) + +Revision ID: 20251028_ghost_content +Revises: 0d767ad92dd7 +Create Date: 2025-10-28 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20251028_ghost_content" +down_revision = "0d767ad92dd7" +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + "authors", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("slug", sa.String(length=191), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("profile_image", sa.Text(), nullable=True), + sa.Column("cover_image", sa.Text(), nullable=True), + sa.Column("bio", sa.Text(), nullable=True), + sa.Column("website", sa.Text(), nullable=True), + sa.Column("location", sa.Text(), nullable=True), + sa.Column("facebook", sa.Text(), nullable=True), + sa.Column("twitter", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("ghost_id", name="uq_authors_ghost_id"), + ) + op.create_index("ix_authors_ghost_id", "authors", ["ghost_id"]) + op.create_index("ix_authors_slug", "authors", ["slug"]) + + op.create_table( + "tags", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("slug", sa.String(length=191), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("visibility", sa.String(length=32), nullable=False, server_default="public"), + sa.Column("feature_image", sa.Text(), nullable=True), + sa.Column("meta_title", sa.String(length=300), nullable=True), + sa.Column("meta_description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("ghost_id", name="uq_tags_ghost_id"), + ) + op.create_index("ix_tags_ghost_id", "tags", ["ghost_id"]) + op.create_index("ix_tags_slug", "tags", ["slug"]) + + op.create_table( + "posts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("uuid", sa.String(length=64), nullable=False), + sa.Column("slug", sa.String(length=191), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("html", sa.Text(), nullable=True), + sa.Column("plaintext", sa.Text(), nullable=True), + sa.Column("mobiledoc", sa.Text(), nullable=True), + sa.Column("lexical", sa.Text(), nullable=True), + sa.Column("feature_image", sa.Text(), nullable=True), + sa.Column("feature_image_alt", sa.Text(), nullable=True), + sa.Column("feature_image_caption", sa.Text(), nullable=True), + sa.Column("excerpt", sa.Text(), nullable=True), + sa.Column("custom_excerpt", sa.Text(), nullable=True), + sa.Column("visibility", sa.String(length=32), nullable=False, server_default="public"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"), + sa.Column("featured", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_page", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("email_only", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("canonical_url", sa.Text(), nullable=True), + sa.Column("meta_title", sa.String(length=500), nullable=True), + sa.Column("meta_description", sa.Text(), nullable=True), + sa.Column("og_image", sa.Text(), nullable=True), + sa.Column("og_title", sa.String(length=500), nullable=True), + sa.Column("og_description", sa.Text(), nullable=True), + sa.Column("twitter_image", sa.Text(), nullable=True), + sa.Column("twitter_title", sa.String(length=500), nullable=True), + sa.Column("twitter_description", sa.Text(), nullable=True), + sa.Column("custom_template", sa.String(length=191), nullable=True), + sa.Column("reading_time", sa.Integer(), nullable=True), + sa.Column("comment_id", sa.String(length=191), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("primary_author_id", sa.Integer(), sa.ForeignKey("authors.id", ondelete="SET NULL"), nullable=True), + sa.Column("primary_tag_id", sa.Integer(), sa.ForeignKey("tags.id", ondelete="SET NULL"), nullable=True), + sa.UniqueConstraint("ghost_id", name="uq_posts_ghost_id"), + sa.UniqueConstraint("uuid", name="uq_posts_uuid"), + ) + op.create_index("ix_posts_ghost_id", "posts", ["ghost_id"]) + op.create_index("ix_posts_slug", "posts", ["slug"]) + op.create_index("ix_posts_status", "posts", ["status"]) + op.create_index("ix_posts_visibility", "posts", ["visibility"]) + op.create_index("ix_posts_is_page", "posts", ["is_page"]) + op.create_index("ix_posts_published_at", "posts", ["published_at"]) + + op.create_table( + "post_authors", + sa.Column("post_id", sa.Integer(), sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True), + sa.Column("author_id", sa.Integer(), sa.ForeignKey("authors.id", ondelete="CASCADE"), primary_key=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + ) + + op.create_table( + "post_tags", + sa.Column("post_id", sa.Integer(), sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True), + sa.Column("tag_id", sa.Integer(), sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade(): + op.drop_table("post_tags") + op.drop_table("post_authors") + op.drop_index("ix_posts_published_at", table_name="posts") + op.drop_index("ix_posts_is_page", table_name="posts") + op.drop_index("ix_posts_visibility", table_name="posts") + op.drop_index("ix_posts_status", table_name="posts") + op.drop_index("ix_posts_slug", table_name="posts") + op.drop_index("ix_posts_ghost_id", table_name="posts") + op.drop_table("posts") + op.drop_index("ix_tags_slug", table_name="tags") + op.drop_index("ix_tags_ghost_id", table_name="tags") + op.drop_table("tags") + op.drop_index("ix_authors_slug", table_name="authors") + op.drop_index("ix_authors_ghost_id", table_name="authors") + op.drop_table("authors") diff --git a/alembic/old_versions/20251102_223123_extend_user_for_ghost_membership.py b/alembic/old_versions/20251102_223123_extend_user_for_ghost_membership.py new file mode 100644 index 0000000..1ce1b45 --- /dev/null +++ b/alembic/old_versions/20251102_223123_extend_user_for_ghost_membership.py @@ -0,0 +1,128 @@ + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "20251102_223123" +down_revision = "20251028_ghost_content" +branch_labels = None +depends_on = None + +def upgrade(): + # Extend users + op.add_column("users", sa.Column("ghost_id", sa.String(length=64), nullable=True)) + op.add_column("users", sa.Column("name", sa.String(length=255), nullable=True)) + op.add_column("users", sa.Column("ghost_status", sa.String(length=50), nullable=True)) + op.add_column("users", sa.Column("ghost_subscribed", sa.Boolean(), nullable=False, server_default=sa.true())) + op.add_column("users", sa.Column("ghost_note", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("avatar_image", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("stripe_customer_id", sa.String(length=255), nullable=True)) + op.add_column("users", sa.Column("ghost_raw", postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.create_index("ix_users_ghost_id", "users", ["ghost_id"], unique=True) + op.create_index("ix_users_stripe_customer_id", "users", ["stripe_customer_id"]) + + # Labels + op.create_table( + "ghost_labels", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_ghost_labels_ghost_id", "ghost_labels", ["ghost_id"], unique=True) + + op.create_table( + "user_labels", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("label_id", sa.Integer(), sa.ForeignKey("ghost_labels.id", ondelete="CASCADE"), nullable=False), + sa.UniqueConstraint("user_id", "label_id", name="uq_user_label"), + ) + + # Newsletters + op.create_table( + "ghost_newsletters", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_ghost_newsletters_ghost_id", "ghost_newsletters", ["ghost_id"], unique=True) + + op.create_table( + "user_newsletters", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("newsletter_id", sa.Integer(), sa.ForeignKey("ghost_newsletters.id", ondelete="CASCADE"), nullable=False), + sa.Column("subscribed", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.UniqueConstraint("user_id", "newsletter_id", name="uq_user_newsletter"), + ) + + # Tiers + op.create_table( + "ghost_tiers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=True), + sa.Column("type", sa.String(length=50), nullable=True), + sa.Column("visibility", sa.String(length=50), nullable=True), + ) + op.create_index("ix_ghost_tiers_ghost_id", "ghost_tiers", ["ghost_id"], unique=True) + + # Subscriptions + op.create_table( + "ghost_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ghost_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("status", sa.String(length=50), nullable=True), + sa.Column("tier_id", sa.Integer(), sa.ForeignKey("ghost_tiers.id", ondelete="SET NULL"), nullable=True), + sa.Column("cadence", sa.String(length=50), nullable=True), + sa.Column("price_amount", sa.Integer(), nullable=True), + sa.Column("price_currency", sa.String(length=10), nullable=True), + sa.Column("stripe_customer_id", sa.String(length=255), nullable=True), + sa.Column("stripe_subscription_id", sa.String(length=255), nullable=True), + sa.Column("raw", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.create_index("ix_ghost_subscriptions_ghost_id", "ghost_subscriptions", ["ghost_id"], unique=True) + op.create_index("ix_ghost_subscriptions_user_id", "ghost_subscriptions", ["user_id"]) + op.create_index("ix_ghost_subscriptions_tier_id", "ghost_subscriptions", ["tier_id"]) + op.create_index("ix_ghost_subscriptions_stripe_customer_id", "ghost_subscriptions", ["stripe_customer_id"]) + op.create_index("ix_ghost_subscriptions_stripe_subscription_id", "ghost_subscriptions", ["stripe_subscription_id"]) + +def downgrade(): + op.drop_index("ix_ghost_subscriptions_stripe_subscription_id", table_name="ghost_subscriptions") + op.drop_index("ix_ghost_subscriptions_stripe_customer_id", table_name="ghost_subscriptions") + op.drop_index("ix_ghost_subscriptions_tier_id", table_name="ghost_subscriptions") + op.drop_index("ix_ghost_subscriptions_user_id", table_name="ghost_subscriptions") + op.drop_index("ix_ghost_subscriptions_ghost_id", table_name="ghost_subscriptions") + op.drop_table("ghost_subscriptions") + + op.drop_index("ix_ghost_tiers_ghost_id", table_name="ghost_tiers") + op.drop_table("ghost_tiers") + + op.drop_table("user_newsletters") + op.drop_index("ix_ghost_newsletters_ghost_id", table_name="ghost_newsletters") + op.drop_table("ghost_newsletters") + + op.drop_table("user_labels") + op.drop_index("ix_ghost_labels_ghost_id", table_name="ghost_labels") + op.drop_table("ghost_labels") + + op.drop_index("ix_users_stripe_customer_id", table_name="users") + op.drop_index("ix_users_ghost_id", table_name="users") + op.drop_column("users", "ghost_raw") + op.drop_column("users", "stripe_customer_id") + op.drop_column("users", "avatar_image") + op.drop_column("users", "ghost_note") + op.drop_column("users", "ghost_subscribed") + op.drop_column("users", "ghost_status") + op.drop_column("users", "name") + op.drop_column("users", "ghost_id") diff --git a/alembic/old_versions/20251106_152905_calendar_config.py b/alembic/old_versions/20251106_152905_calendar_config.py new file mode 100644 index 0000000..3d20141 --- /dev/null +++ b/alembic/old_versions/20251106_152905_calendar_config.py @@ -0,0 +1,62 @@ +"""add calendar description and slots + +Revision ID: 20251106_152905_calendar_config +Revises: 215330c5ec15 +Create Date: 2025-11-06T15:29:05.243479 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20251106_152905_calendar_config" +down_revision = "215330c5ec15" +branch_labels = None +depends_on = None + +def upgrade() -> None: + with op.batch_alter_table("calendars") as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + + op.create_table( + "calendar_slots", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + + sa.Column("mon", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("tue", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("wed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("thu", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("fri", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("sat", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("sun", sa.Boolean(), nullable=False, server_default=sa.false()), + + sa.Column("time_start", sa.Time(timezone=False), nullable=False), + sa.Column("time_end", sa.Time(timezone=False), nullable=False), + + sa.Column("cost", sa.Numeric(10, 2), nullable=True), + + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("(time_end > time_start)", name="ck_calendar_slots_time_end_after_start"), + ) + + op.create_index("ix_calendar_slots_calendar_id", "calendar_slots", ["calendar_id"], unique=False) + op.create_index("ix_calendar_slots_time_start", "calendar_slots", ["time_start"], unique=False) + + op.create_unique_constraint( + "uq_calendar_slots_unique_band", + "calendar_slots", + ["calendar_id", "name"] + ) + +def downgrade() -> None: + op.drop_constraint("uq_calendar_slots_unique_band", "calendar_slots", type_="unique") + op.drop_index("ix_calendar_slots_time_start", table_name="calendar_slots") + op.drop_index("ix_calendar_slots_calendar_id", table_name="calendar_slots") + op.drop_table("calendar_slots") + + with op.batch_alter_table("calendars") as batch_op: + batch_op.drop_column("description") diff --git a/alembic/old_versions/20251107_121500_add_labels_stickers.py b/alembic/old_versions/20251107_121500_add_labels_stickers.py new file mode 100644 index 0000000..0c9d869 --- /dev/null +++ b/alembic/old_versions/20251107_121500_add_labels_stickers.py @@ -0,0 +1,52 @@ +"""add product labels and stickers + +Revision ID: 20251107_121500_add_labels_stickers +Revises: 20251107_090500_snapshot_to_db +Create Date: 2025-11-07T12:15:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20251107_121500_labels_stickers" +down_revision = "20251107_090500_snapshot_to_db" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "product_labels", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + ) + op.create_index("ix_product_labels_product_id", "product_labels", ["product_id"], unique=False) + op.create_index("ix_product_labels_name", "product_labels", ["name"], unique=False) + op.create_unique_constraint( + "uq_product_labels_product_name", "product_labels", ["product_id", "name"] + ) + + op.create_table( + "product_stickers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + ) + op.create_index("ix_product_stickers_product_id", "product_stickers", ["product_id"], unique=False) + op.create_index("ix_product_stickers_name", "product_stickers", ["name"], unique=False) + op.create_unique_constraint( + "uq_product_stickers_product_name", "product_stickers", ["product_id", "name"] + ) + + +def downgrade() -> None: + op.drop_constraint("uq_product_stickers_product_name", "product_stickers", type_="unique") + op.drop_index("ix_product_stickers_name", table_name="product_stickers") + op.drop_index("ix_product_stickers_product_id", table_name="product_stickers") + op.drop_table("product_stickers") + + op.drop_constraint("uq_product_labels_product_name", "product_labels", type_="unique") + op.drop_index("ix_product_labels_name", table_name="product_labels") + op.drop_index("ix_product_labels_product_id", table_name="product_labels") + op.drop_table("product_labels") diff --git a/alembic/old_versions/20251107_123000_widen_alembic_version.py b/alembic/old_versions/20251107_123000_widen_alembic_version.py new file mode 100644 index 0000000..defea29 --- /dev/null +++ b/alembic/old_versions/20251107_123000_widen_alembic_version.py @@ -0,0 +1,44 @@ +"""widen alembic_version.version_num to 255 + +Revision ID: 20251107_123000_widen_alembic_version +Revises: 20251107_121500_labels_stickers +Create Date: 2025-11-07T12:30:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20251107_123000_widen_alembic_version" +down_revision = "20251107_121500_labels_stickers" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Increase the size of alembic_version.version_num to 255.""" + + # Most projects use Postgres; this raw SQL is explicit and works reliably. + # Widening requires no USING clause on Postgres, but we'll be explicit for clarity. + op.execute( + "ALTER TABLE alembic_version " + "ALTER COLUMN version_num TYPE VARCHAR(255)" + ) + + # If you need cross-dialect support later, you could add dialect checks + # and use batch_alter_table for SQLite. For your Postgres setup, this is sufficient. + + +def downgrade() -> None: + """Shrink alembic_version.version_num back to 32. + + On Postgres, shrinking can fail if any row exceeds 32 chars. + We proactively truncate to 32 to guarantee a clean downgrade. + """ + # Truncate any too-long values to fit back into VARCHAR(32) + op.execute( + "UPDATE alembic_version SET version_num = LEFT(version_num, 32)" + ) + op.execute( + "ALTER TABLE alembic_version " + "ALTER COLUMN version_num TYPE VARCHAR(32)" + ) diff --git a/alembic/old_versions/20251107_153000_product_attributes_nutrition.py b/alembic/old_versions/20251107_153000_product_attributes_nutrition.py new file mode 100644 index 0000000..01f415e --- /dev/null +++ b/alembic/old_versions/20251107_153000_product_attributes_nutrition.py @@ -0,0 +1,93 @@ +"""add product attributes, nutrition, allergens and extra product columns + +Revision ID: 20251107_153000_product_attributes_nutrition +Revises: 20251107_123000_widen_alembic_version +Create Date: 2025-11-07T15:30:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20251107_153000_product_attributes_nutrition" +down_revision = "20251107_123000_widen_alembic_version" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- products extra columns --- + with op.batch_alter_table("products") as batch_op: + batch_op.add_column(sa.Column("ean", sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column("sku", sa.String(length=128), nullable=True)) + batch_op.add_column(sa.Column("unit_size", sa.String(length=128), nullable=True)) + batch_op.add_column(sa.Column("pack_size", sa.String(length=128), nullable=True)) + batch_op.create_index("ix_products_ean", ["ean"], unique=False) + batch_op.create_index("ix_products_sku", ["sku"], unique=False) + + # --- attributes: arbitrary key/value facts (e.g., Brand, Origin, etc.) --- + op.create_table( + "product_attributes", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("value", sa.Text(), nullable=True), + ) + op.create_index("ix_product_attributes_product_id", "product_attributes", ["product_id"], unique=False) + op.create_index("ix_product_attributes_key", "product_attributes", ["key"], unique=False) + op.create_unique_constraint( + "uq_product_attributes_product_key", "product_attributes", ["product_id", "key"] + ) + + # --- nutrition: key/value[+unit] rows (e.g., Energy, Fat, Protein) --- + op.create_table( + "product_nutrition", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("value", sa.String(length=255), nullable=True), + sa.Column("unit", sa.String(length=64), nullable=True), + ) + op.create_index("ix_product_nutrition_product_id", "product_nutrition", ["product_id"], unique=False) + op.create_index("ix_product_nutrition_key", "product_nutrition", ["key"], unique=False) + op.create_unique_constraint( + "uq_product_nutrition_product_key", "product_nutrition", ["product_id", "key"] + ) + + # --- allergens: one row per allergen mention (name + contains boolean) --- + op.create_table( + "product_allergens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("contains", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.create_index("ix_product_allergens_product_id", "product_allergens", ["product_id"], unique=False) + op.create_index("ix_product_allergens_name", "product_allergens", ["name"], unique=False) + op.create_unique_constraint( + "uq_product_allergens_product_name", "product_allergens", ["product_id", "name"] + ) + + +def downgrade() -> None: + op.drop_constraint("uq_product_allergens_product_name", "product_allergens", type_="unique") + op.drop_index("ix_product_allergens_name", table_name="product_allergens") + op.drop_index("ix_product_allergens_product_id", table_name="product_allergens") + op.drop_table("product_allergens") + + op.drop_constraint("uq_product_nutrition_product_key", "product_nutrition", type_="unique") + op.drop_index("ix_product_nutrition_key", table_name="product_nutrition") + op.drop_index("ix_product_nutrition_product_id", table_name="product_nutrition") + op.drop_table("product_nutrition") + + op.drop_constraint("uq_product_attributes_product_key", "product_attributes", type_="unique") + op.drop_index("ix_product_attributes_key", table_name="product_attributes") + op.drop_index("ix_product_attributes_product_id", table_name="product_attributes") + op.drop_table("product_attributes") + + with op.batch_alter_table("products") as batch_op: + batch_op.drop_index("ix_products_sku") + batch_op.drop_index("ix_products_ean") + batch_op.drop_column("pack_size") + batch_op.drop_column("unit_size") + batch_op.drop_column("sku") + batch_op.drop_column("ean") diff --git a/alembic/old_versions/20251107_163500_add_regular_price_and_oe_list_price.py b/alembic/old_versions/20251107_163500_add_regular_price_and_oe_list_price.py new file mode 100644 index 0000000..d347ae4 --- /dev/null +++ b/alembic/old_versions/20251107_163500_add_regular_price_and_oe_list_price.py @@ -0,0 +1,30 @@ +"""Add regular_price and oe_list_price fields to Product + +Revision ID: 20251107_163500_add_regular_price_and_oe_list_price +Revises: 20251107_153000_product_attributes_nutrition +Create Date: 2025-11-07 16:35:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20251107_163500_add_regular_price_and_oe_list_price" +down_revision = "20251107_153000_product_attributes_nutrition" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('products', sa.Column('regular_price', sa.Numeric(12, 2), nullable=True)) + op.add_column('products', sa.Column('regular_price_currency', sa.String(length=16), nullable=True)) + op.add_column('products', sa.Column('regular_price_raw', sa.String(length=128), nullable=True)) + op.add_column('products', sa.Column('oe_list_price', sa.Numeric(12, 2), nullable=True)) + + +def downgrade(): + op.drop_column('products', 'oe_list_price') + op.drop_column('products', 'regular_price_raw') + op.drop_column('products', 'regular_price_currency') + op.drop_column('products', 'regular_price') diff --git a/alembic/old_versions/20251107_180000_link_listings_to_nav_ids.py b/alembic/old_versions/20251107_180000_link_listings_to_nav_ids.py new file mode 100644 index 0000000..46b8f24 --- /dev/null +++ b/alembic/old_versions/20251107_180000_link_listings_to_nav_ids.py @@ -0,0 +1,72 @@ +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column, select, update +from sqlalchemy.orm.session import Session + +# revision identifiers, used by Alembic. +revision = '20251107_180000_link_listings_to_nav_ids' +down_revision = '20251107_163500_add_regular_price_and_oe_list_price' +branch_labels = None +depends_on = None + +def upgrade(): + # Add new nullable columns first + op.add_column('listings', sa.Column('top_id', sa.Integer(), nullable=True)) + op.add_column('listings', sa.Column('sub_id', sa.Integer(), nullable=True)) + + bind = op.get_bind() + session = Session(bind=bind) + + nav_tops = sa.table( + 'nav_tops', + sa.column('id', sa.Integer), + sa.column('slug', sa.String), + ) + nav_subs = sa.table( + 'nav_subs', + sa.column('id', sa.Integer), + sa.column('slug', sa.String), + sa.column('top_id', sa.Integer), + ) + listings = sa.table( + 'listings', + sa.column('id', sa.Integer), + sa.column('top_slug', sa.String), + sa.column('sub_slug', sa.String), + sa.column('top_id', sa.Integer), + sa.column('sub_id', sa.Integer), + ) + + # Map top_slug -> top_id + top_slug_to_id = { + slug: id_ for id_, slug in session.execute(select(nav_tops.c.id, nav_tops.c.slug)) + } + + sub_slug_to_id = { + (top_id, slug): id_ for id_, slug, top_id in session.execute( + select(nav_subs.c.id, nav_subs.c.slug, nav_subs.c.top_id) + ) + } + + for row in session.execute(select(listings.c.id, listings.c.top_slug, listings.c.sub_slug)): + listing_id, top_slug, sub_slug = row + top_id = top_slug_to_id.get(top_slug) + sub_id = sub_slug_to_id.get((top_id, sub_slug)) if sub_slug else None + session.execute( + listings.update() + .where(listings.c.id == listing_id) + .values(top_id=top_id, sub_id=sub_id) + ) + session.commit() + + # Add foreign keys and constraints + op.create_foreign_key(None, 'listings', 'nav_tops', ['top_id'], ['id']) + op.create_foreign_key(None, 'listings', 'nav_subs', ['sub_id'], ['id']) + op.alter_column('listings', 'top_id', nullable=False) + + # Optional: remove old slug fields + # op.drop_column('listings', 'top_slug') + # op.drop_column('listings', 'sub_slug') + +def downgrade(): + raise NotImplementedError("No downgrade") diff --git a/alembic/old_versions/20251107_add_missing_indexes.py b/alembic/old_versions/20251107_add_missing_indexes.py new file mode 100644 index 0000000..7e0738e --- /dev/null +++ b/alembic/old_versions/20251107_add_missing_indexes.py @@ -0,0 +1,26 @@ +from alembic import op +import sqlalchemy as sa + +revision = '20251107_add_missing_indexes' +down_revision = '1a1f1f1fc71c' # Adjust if needed to match your current head +depends_on = None +branch_labels = None + +def upgrade() -> None: + # Index for sorting by price + op.create_index("ix_products_regular_price", "products", ["regular_price"]) + + # Index for filtering/aggregating by brand + op.create_index("ix_products_brand", "products", ["brand"]) + + # Index for product_likes.product_id (if not already covered by FK) + op.create_index("ix_product_likes_product_id", "product_likes", ["product_id"]) + + # Composite index on listing_items (may be partially redundant with existing constraints) + op.create_index("ix_listing_items_listing_slug", "listing_items", ["listing_id", "slug"]) + +def downgrade() -> None: + op.drop_index("ix_listing_items_listing_slug", table_name="listing_items") + op.drop_index("ix_product_likes_product_id", table_name="product_likes") + op.drop_index("ix_products_brand", table_name="products") + op.drop_index("ix_products_regular_price", table_name="products") diff --git a/alembic/old_versions/20251107_add_product_id_to_likes.py b/alembic/old_versions/20251107_add_product_id_to_likes.py new file mode 100644 index 0000000..bef2801 --- /dev/null +++ b/alembic/old_versions/20251107_add_product_id_to_likes.py @@ -0,0 +1,52 @@ +"""Add surrogate key and product_id FK to product_likes""" + +from alembic import op +import sqlalchemy as sa + +# Revision identifiers +revision = '20251107_add_product_id_to_likes' +down_revision = '0d767ad92dd7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add surrogate primary key and product_id foreign key column + op.add_column("product_likes", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True)) + op.add_column("product_likes", sa.Column("product_id", sa.Integer(), nullable=True)) + + # Create temporary FK without constraint for backfill + op.execute(""" + UPDATE product_likes + SET product_id = ( + SELECT id FROM products WHERE products.slug = product_likes.product_slug + ) + """) + + # Add real FK constraint + op.create_foreign_key( + "fk_product_likes_product_id_products", + source_table="product_likes", + referent_table="products", + local_cols=["product_id"], + remote_cols=["id"], + ondelete="CASCADE" + ) + + # Make product_id non-nullable now that it’s backfilled + op.alter_column("product_likes", "product_id", nullable=False) + + # Add index for efficient lookup + op.create_index( + "ix_product_likes_user_product", + "product_likes", + ["user_id", "product_id"], + unique=True + ) + + +def downgrade() -> None: + op.drop_index("ix_product_likes_user_product", table_name="product_likes") + op.drop_constraint("fk_product_likes_product_id_products", "product_likes", type_="foreignkey") + op.drop_column("product_likes", "product_id") + op.drop_column("product_likes", "id") diff --git a/alembic/old_versions/20251108_003803_soft_delete_all.py b/alembic/old_versions/20251108_003803_soft_delete_all.py new file mode 100644 index 0000000..dcf13d7 --- /dev/null +++ b/alembic/old_versions/20251108_003803_soft_delete_all.py @@ -0,0 +1,164 @@ +"""Add soft delete and update unique constraints to include deleted_at + +Revision ID: soft_delete_all +Revises: +Create Date: 2025-11-08 00:38:03 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20251108_soft_delete_all' +down_revision = 'remove_product_slug_20251107' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.add_column('product_likes', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_product_likes_product_user', 'product_likes', ['product_id', 'user_id', 'deleted_at']) + + # Drop the old unique index + op.drop_index('ix_product_likes_user_product', table_name='product_likes') + + # Create a new unique index that includes deleted_at + op.create_index( + 'ix_product_likes_user_product', + 'product_likes', + ['user_id', 'product_id', 'deleted_at'], + unique=True + ) + + + op.add_column('product_allergens', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_allergens', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.drop_constraint('uq_product_allergens_product_name', 'product_allergens', type_='unique') + op.create_unique_constraint('uq_product_allergens_product_name', 'product_allergens', ['product_id', 'name', 'deleted_at']) + + + op.add_column('product_attributes', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_attributes', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.drop_constraint('uq_product_attributes_product_key', 'product_attributes', type_='unique') + op.create_unique_constraint('uq_product_attributes_product_key', 'product_attributes', ['product_id', 'key', 'deleted_at']) + + + op.add_column('product_images', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_images', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_product_images', 'product_images', ['product_id', 'position', 'deleted_at']) + + + + op.add_column('product_labels', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_labels', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_product_labels', 'product_labels', ['product_id', 'name', 'deleted_at']) + + + + op.add_column('product_nutrition', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_nutrition', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_product_nutrition', 'product_nutrition', ['product_id', 'key', 'deleted_at']) + + + op.add_column('product_sections', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_sections', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_product_sections', 'product_sections', ['product_id', 'title', 'deleted_at']) + + + op.add_column('product_stickers', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('product_stickers', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_product_stickers', 'product_stickers', ['product_id', 'name', 'deleted_at']) + + + op.add_column('nav_tops', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_nav_tops', 'nav_tops', ['slug', 'deleted_at']) + + + + op.add_column('nav_subs', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('nav_subs', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_nav_subs', 'nav_subs', ['top_id', 'slug', 'deleted_at']) + + + + op.add_column('listings', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.drop_constraint('uq_listings_top_sub', 'listings', type_='unique') + op.create_unique_constraint('uq_listings_top_sub', 'listings', ['top_id', 'sub_id', 'deleted_at']) + + op.add_column('listing_items', sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False)) + op.add_column('listing_items', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + op.create_unique_constraint('uq_listing_items', 'listing_items', ['listing_id', 'slug', 'deleted_at']) + +def downgrade() -> None: + + + # Drop the modified index + op.drop_index('ix_product_likes_user_product', table_name='product_likes') + + # Recreate the old unique index + op.create_index( + 'ix_product_likes_user_product', + 'product_likes', + ['user_id', 'product_id'], + unique=True + ) + op.drop_constraint('uq_product_likes_product_user', 'product_likes', type_='unique') + op.drop_column('product_likes', 'deleted_at') + + + + op.drop_constraint('uq_product_allergens_product_name', 'product_allergens', type_='unique') + op.drop_column('product_allergens', 'deleted_at') + op.drop_column('product_allergens', 'created_at') + op.create_unique_constraint('uq_product_allergens_product_name', 'product_allergens', ['product_id', 'name']) + + op.drop_constraint('uq_product_attributes_product_key', 'product_attributes', type_='unique') + op.drop_column('product_attributes', 'deleted_at') + op.drop_column('product_attributes', 'created_at') + op.create_unique_constraint('uq_product_attributes_product_key', 'product_attributes', ['product_id', 'key']) + + + op.drop_constraint('uq_product_images', 'product_images', type_='unique') + op.drop_column('product_images', 'deleted_at') + op.drop_column('product_images', 'created_at') + + + op.drop_constraint('uq_product_labels', 'product_labels', type_='unique') + op.drop_column('product_labels', 'deleted_at') + op.drop_column('product_labels', 'created_at') + + + + op.drop_constraint('uq_product_nutrition', 'product_nutrition', type_='unique') + op.drop_column('product_nutrition', 'deleted_at') + op.drop_column('product_nutrition', 'created_at') + + + + op.drop_constraint('uq_product_sections', 'product_sections', type_='unique') + op.drop_column('product_sections', 'deleted_at') + op.drop_column('product_sections', 'created_at') + + + op.drop_constraint('uq_product_stickers', 'product_stickers', type_='unique') + op.drop_column('product_stickers', 'deleted_at') + op.drop_column('product_stickers', 'created_at') + + + + op.drop_constraint('uq_nav_tops', 'nav_tops', type_='unique') + op.drop_column('nav_tops', 'deleted_at') + + + op.drop_constraint('uq_nav_subs', 'nav_subs', type_='unique') + op.drop_column('nav_subs', 'deleted_at') + op.drop_column('nav_subs', 'created_at') + + + op.drop_constraint('uq_listings_top_sub', 'listings', type_='unique') + op.drop_column('listings', 'deleted_at') + op.create_unique_constraint('uq_listings_top_sub', 'listings', ['top_id', 'sub_id']) + + op.drop_constraint('uq_listing_items', 'listing_items', type_='unique') + op.drop_column('listing_items', 'deleted_at') + op.drop_column('listing_items', 'created_at') + \ No newline at end of file diff --git a/alembic/old_versions/20251108_1_remove extra_uqs.py b/alembic/old_versions/20251108_1_remove extra_uqs.py new file mode 100644 index 0000000..971af5a --- /dev/null +++ b/alembic/old_versions/20251108_1_remove extra_uqs.py @@ -0,0 +1,60 @@ +"""Update nav_tops unique constraint to include deleted_at""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision='20251108_1_remove extra_uqs' +down_revision = '20251108_nav_tops_soft_delete' +branch_labels = None +depends_on = None + + +def upgrade(): + + # Drop existing constraint + op.drop_constraint('uq_product_images', 'product_images', type_='unique') + op.drop_constraint('uq_product_images_product_url_kind', 'product_images', type_='unique') + op.create_unique_constraint("uq_product_images_product_url_kind", "product_images", ["product_id", "url", "kind", "deleted_at"]) + + op.drop_constraint('uq_product_labels', 'product_labels', type_='unique') + op.drop_constraint('uq_product_labels_product_name', 'product_labels', type_='unique') + op.create_unique_constraint("uq_product_labels_product_name", "product_labels", ["product_id", "name", "deleted_at"]) + + op.drop_constraint('uq_product_nutrition', 'product_nutrition', type_='unique') + op.drop_constraint('uq_product_nutrition_product_key', 'product_nutrition', type_='unique') + op.create_unique_constraint("uq_product_nutrition_product_key", "product_nutrition", ["product_id", "key", "deleted_at"]) + + op.drop_constraint('uq_product_sections', 'product_sections', type_='unique') + op.drop_constraint('uq_product_sections_product_title', 'product_sections', type_='unique') + op.create_unique_constraint("uq_product_sections_product_title", "product_sections", ["product_id", "title", "deleted_at"]) + + op.drop_constraint('uq_product_stickers', 'product_stickers', type_='unique') + op.drop_constraint('uq_product_stickers_product_name', 'product_stickers', type_='unique') + op.create_unique_constraint("uq_product_stickers_product_name", "product_stickers", ["product_id", "name", "deleted_at"]) + + +def downgrade(): + # Restore old constraint + + op.drop_constraint('uq_product_images_product_url_kind', 'product_images', type_='unique') + op.create_unique_constraint("uq_product_images_product_url_kind", "product_images", ["product_id", "url", "kind"]) + op.create_unique_constraint("uq_product_images", "product_images", ["product_id", "position", "deleted_at"]) + + op.drop_constraint('uq_product_labels_product_name', 'product_labels', type_='unique') + op.create_unique_constraint("uq_product_labels_product_name", "product_labels", ["product_id", "name"]) + op.create_unique_constraint("uq_product_labels", "product_labels", ["product_id", "name", "deleted_at"]) + + op.drop_constraint('uq_product_nutrition_product_key', 'product_nutrition', type_='unique') + op.create_unique_constraint("uq_product_nutrition_product_key", "product_nutrition", ["product_id", "key"]) + op.create_unique_constraint("uq_product_nutrition", "product_nutrition", ["product_id", "key", "deleted_at"]) + + op.drop_constraint('uq_product_sections_product_title', 'product_sections', type_='unique') + op.create_unique_constraint("uq_product_sections_product_title", "product_sections", ["product_id", "title"]) + op.create_unique_constraint("uq_product_sections", "product_sections", ["product_id", "title", "deleted_at"]) + + op.drop_constraint('uq_product_stickers_product_name', 'product_stickers', type_='unique') + op.create_unique_constraint("uq_product_stickers_product_name", "product_stickers", ["product_id", "name", ]) + op.create_unique_constraint("uq_product_stickers", "product_stickers", ["product_id", "name", "deleted_at" ]) + \ No newline at end of file diff --git a/alembic/old_versions/20251108_nav_tops_soft_delete.py b/alembic/old_versions/20251108_nav_tops_soft_delete.py new file mode 100644 index 0000000..1159f94 --- /dev/null +++ b/alembic/old_versions/20251108_nav_tops_soft_delete.py @@ -0,0 +1,36 @@ +"""Update nav_tops unique constraint to include deleted_at""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251108_nav_tops_soft_delete' +down_revision = '20251108_soft_delete_all' +branch_labels = None +depends_on = None + + +def upgrade(): + + # Drop existing constraint + op.drop_constraint('uq_nav_tops_label_slug', 'nav_tops', type_='unique') + + # Add new constraint including deleted_at + op.create_unique_constraint( + 'uq_nav_tops_label_slug', + 'nav_tops', + ['label', 'slug', 'deleted_at'] + ) + + +def downgrade(): + # Drop new constraint + op.drop_constraint('uq_nav_tops_label_slug', 'nav_tops', type_='unique') + + # Restore old constraint + op.create_unique_constraint( + 'uq_nav_tops_label_slug', + 'nav_tops', + ['label', 'slug'] + ) diff --git a/alembic/old_versions/215330c5ec15_add_calendars_and_entries.py b/alembic/old_versions/215330c5ec15_add_calendars_and_entries.py new file mode 100644 index 0000000..6316c3f --- /dev/null +++ b/alembic/old_versions/215330c5ec15_add_calendars_and_entries.py @@ -0,0 +1,92 @@ +"""add calendars & calendar_entries + +Revision ID: 215330c5ec15 +Revises: 20251102_223123 +Create Date: 2025-11-03 13:07:10.387189 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "215330c5ec15" +down_revision = "20251102_223123" +branch_labels = None +depends_on = None + + +def upgrade(): + # calendars + op.create_table( + "calendars", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("post_id", sa.Integer(), sa.ForeignKey("posts.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + # no hard UniqueConstraint; we enforce soft-delete-aware uniqueness with a partial index below + ) + # helpful lookup indexes + op.create_index("ix_calendars_post_id", "calendars", ["post_id"], unique=False) + op.create_index("ix_calendars_name", "calendars", ["name"], unique=False) + op.create_index("ix_calendars_slug", "calendars", ["slug"], unique=False) + + # calendar_entries + op.create_table( + "calendar_entries", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_at", sa.DateTime(timezone=True), nullable=True), # <- allow open-ended + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("(end_at IS NULL) OR (end_at >= start_at)", name="ck_calendar_entries_end_after_start"), + ) + op.create_index("ix_calendar_entries_calendar_id", "calendar_entries", ["calendar_id"], unique=False) + op.create_index("ix_calendar_entries_start_at", "calendar_entries", ["start_at"], unique=False) + op.create_index("ix_calendar_entries_name", "calendar_entries", ["name"], unique=False) + + # ---- Soft-delete-aware uniqueness for calendars (Postgres) ---- + # One active calendar per (post_id, lower(slug)) + if op.get_bind().dialect.name == "postgresql": + # cleanup any active duplicates (defensive; table is new on fresh runs) + op.execute(""" + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY post_id, lower(slug) + ORDER BY updated_at DESC, created_at DESC, id DESC + ) AS rn + FROM calendars + WHERE deleted_at IS NULL + ) + UPDATE calendars c + SET deleted_at = NOW() + FROM ranked r + WHERE c.id = r.id AND r.rn > 1; + """) + op.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS ux_calendars_post_slug_active + ON calendars (post_id, lower(slug)) + WHERE deleted_at IS NULL; + """) + + +def downgrade(): + # drop in reverse dependency order + op.drop_index("ix_calendar_entries_name", table_name="calendar_entries") + op.drop_index("ix_calendar_entries_start_at", table_name="calendar_entries") + op.drop_index("ix_calendar_entries_calendar_id", table_name="calendar_entries") + op.drop_table("calendar_entries") + + if op.get_bind().dialect.name == "postgresql": + op.execute("DROP INDEX IF EXISTS ux_calendars_post_slug_active;") + + op.drop_index("ix_calendars_slug", table_name="calendars") + op.drop_index("ix_calendars_name", table_name="calendars") + op.drop_index("ix_calendars_post_id", table_name="calendars") + op.drop_table("calendars") diff --git a/alembic/old_versions/remove_product_slug_20251107.py b/alembic/old_versions/remove_product_slug_20251107.py new file mode 100644 index 0000000..9987bbd --- /dev/null +++ b/alembic/old_versions/remove_product_slug_20251107.py @@ -0,0 +1,29 @@ +"""Remove product_slug from product_likes + +Revision ID: remove_product_slug_20251107 +Revises: 0d767ad92dd7 +Create Date: 2025-11-07 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'remove_product_slug_20251107' +down_revision = '20251107_add_missing_indexes' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("product_likes") as batch_op: + batch_op.drop_column("product_slug") + + +def downgrade() -> None: + with op.batch_alter_table("product_likes") as batch_op: + batch_op.add_column(sa.Column( + "product_slug", + sa.String(length=255), + nullable=False, + )) \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..31bee0b --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +<%text> +# Alembic migration script template + +"""empty message + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa + +# 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() -> None: + ${upgrades if upgrades else "pass"} + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0000_alembic.py_ b/alembic/versions/0000_alembic.py_ new file mode 100644 index 0000000..40da2f7 --- /dev/null +++ b/alembic/versions/0000_alembic.py_ @@ -0,0 +1,20 @@ +"""Initial database schema from schema.sql""" + +from alembic import op +import sqlalchemy as sa +import pathlib + +# revision identifiers, used by Alembic +revision = '0000_alembic' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS alembic_version ( + version_num VARCHAR(32) NOT NULL, + CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) + ); + """) + diff --git a/alembic/versions/0001_initial_schem.py b/alembic/versions/0001_initial_schem.py new file mode 100644 index 0000000..b131310 --- /dev/null +++ b/alembic/versions/0001_initial_schem.py @@ -0,0 +1,33 @@ +"""Initial database schema from schema.sql""" + +from alembic import op +import sqlalchemy as sa +import pathlib + +# revision identifiers, used by Alembic +revision = '0001_initial_schema' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + return + schema_path = pathlib.Path(__file__).parent.parent.parent / "schema.sql" + with open(schema_path, encoding="utf-8") as f: + sql = f.read() + conn = op.get_bind() + conn.execute(sa.text(sql)) + +def downgrade(): + return + # Drop all user-defined tables in the 'public' schema + conn = op.get_bind() + conn.execute(sa.text(""" + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + """)) \ No newline at end of file diff --git a/alembic/versions/0002_add_cart_items.py b/alembic/versions/0002_add_cart_items.py new file mode 100644 index 0000000..ecae098 --- /dev/null +++ b/alembic/versions/0002_add_cart_items.py @@ -0,0 +1,78 @@ +"""Add cart_items table for shopping cart""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0002_add_cart_items" +down_revision = "0001_initial_schema" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "cart_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + + # Either a logged-in user *or* an anonymous session_id + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column("session_id", sa.String(length=128), nullable=True), + + # IMPORTANT: reference products.id (PK), not slug + sa.Column( + "product_id", + sa.Integer(), + sa.ForeignKey("products.id", ondelete="CASCADE"), + nullable=False, + ), + + sa.Column( + "quantity", + sa.Integer(), + nullable=False, + server_default="1", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + # Indexes to speed up cart lookups + op.create_index( + "ix_cart_items_user_product", + "cart_items", + ["user_id", "product_id"], + unique=False, + ) + op.create_index( + "ix_cart_items_session_product", + "cart_items", + ["session_id", "product_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_cart_items_session_product", table_name="cart_items") + op.drop_index("ix_cart_items_user_product", table_name="cart_items") + op.drop_table("cart_items") diff --git a/alembic/versions/0003_add_orders.py b/alembic/versions/0003_add_orders.py new file mode 100644 index 0000000..4387219 --- /dev/null +++ b/alembic/versions/0003_add_orders.py @@ -0,0 +1,118 @@ +"""Add orders and order_items tables for checkout""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0003_add_orders" +down_revision = "0002_add_cart_items" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "orders", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("session_id", sa.String(length=64), nullable=True), + + sa.Column( + "status", + sa.String(length=32), + nullable=False, + server_default="pending", + ), + sa.Column( + "currency", + sa.String(length=16), + nullable=False, + server_default="GBP", + ), + sa.Column( + "total_amount", + sa.Numeric(12, 2), + nullable=False, + ), + + # SumUp integration fields + sa.Column("sumup_checkout_id", sa.String(length=128), nullable=True), + sa.Column("sumup_status", sa.String(length=32), nullable=True), + sa.Column("sumup_hosted_url", sa.Text(), nullable=True), + + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + # Indexes to match model hints (session_id + sumup_checkout_id index=True) + op.create_index( + "ix_orders_session_id", + "orders", + ["session_id"], + unique=False, + ) + op.create_index( + "ix_orders_sumup_checkout_id", + "orders", + ["sumup_checkout_id"], + unique=False, + ) + + op.create_table( + "order_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "order_id", + sa.Integer(), + sa.ForeignKey("orders.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "product_id", + sa.Integer(), + sa.ForeignKey("products.id"), + nullable=False, + ), + sa.Column("product_title", sa.String(length=512), nullable=True), + + sa.Column( + "quantity", + sa.Integer(), + nullable=False, + server_default="1", + ), + sa.Column( + "unit_price", + sa.Numeric(12, 2), + nullable=False, + ), + sa.Column( + "currency", + sa.String(length=16), + nullable=False, + server_default="GBP", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("order_items") + op.drop_index("ix_orders_sumup_checkout_id", table_name="orders") + op.drop_index("ix_orders_session_id", table_name="orders") + op.drop_table("orders") diff --git a/alembic/versions/0004_add_sumup_reference.py b/alembic/versions/0004_add_sumup_reference.py new file mode 100644 index 0000000..2738cd2 --- /dev/null +++ b/alembic/versions/0004_add_sumup_reference.py @@ -0,0 +1,27 @@ +"""Add sumup_reference to orders""" + +from alembic import op +import sqlalchemy as sa + +revision = "0004_add_sumup_reference" +down_revision = "0003_add_orders" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "orders", + sa.Column("sumup_reference", sa.String(length=255), nullable=True), + ) + op.create_index( + "ix_orders_sumup_reference", + "orders", + ["sumup_reference"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_orders_sumup_reference", table_name="orders") + op.drop_column("orders", "sumup_reference") diff --git a/alembic/versions/0005_add_description.py b/alembic/versions/0005_add_description.py new file mode 100644 index 0000000..37e84ed --- /dev/null +++ b/alembic/versions/0005_add_description.py @@ -0,0 +1,27 @@ +"""Add description field to orders""" + +from alembic import op +import sqlalchemy as sa + +revision = "0005_add_description" +down_revision = "0004_add_sumup_reference" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "orders", + sa.Column("description", sa.Text(), nullable=True), + ) + op.create_index( + "ix_orders_description", + "orders", + ["description"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_orders_description", table_name="orders") + op.drop_column("orders", "description") diff --git a/alembic/versions/0006_update_calendar_entries.py b/alembic/versions/0006_update_calendar_entries.py new file mode 100644 index 0000000..cd6f9bd --- /dev/null +++ b/alembic/versions/0006_update_calendar_entries.py @@ -0,0 +1,28 @@ +from alembic import op +import sqlalchemy as sa + +revision = '0006_update_calendar_entries' +down_revision = '0005_add_description' # use the appropriate previous revision ID +branch_labels = None +depends_on = None + +def upgrade(): + # Add user_id and session_id columns + op.add_column('calendar_entries', sa.Column('user_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_calendar_entries_user_id', 'calendar_entries', 'users', ['user_id'], ['id']) + op.add_column('calendar_entries', sa.Column('session_id', sa.String(length=128), nullable=True)) + # Add state and cost columns + op.add_column('calendar_entries', sa.Column('state', sa.String(length=20), nullable=False, server_default='pending')) + op.add_column('calendar_entries', sa.Column('cost', sa.Numeric(10,2), nullable=False, server_default='10')) + # (Optional) Create indexes on the new columns + op.create_index('ix_calendar_entries_user_id', 'calendar_entries', ['user_id']) + op.create_index('ix_calendar_entries_session_id', 'calendar_entries', ['session_id']) + +def downgrade(): + op.drop_index('ix_calendar_entries_session_id', table_name='calendar_entries') + op.drop_index('ix_calendar_entries_user_id', table_name='calendar_entries') + op.drop_column('calendar_entries', 'cost') + op.drop_column('calendar_entries', 'state') + op.drop_column('calendar_entries', 'session_id') + op.drop_constraint('fk_calendar_entries_user_id', 'calendar_entries', type_='foreignkey') + op.drop_column('calendar_entries', 'user_id') diff --git a/alembic/versions/0007_add_oid_entries.py b/alembic/versions/0007_add_oid_entries.py new file mode 100644 index 0000000..be05343 --- /dev/null +++ b/alembic/versions/0007_add_oid_entries.py @@ -0,0 +1,50 @@ +from alembic import op +import sqlalchemy as sa + +revision = "0007_add_oid_entries" +down_revision = "0006_update_calendar_entries" +branch_labels = None +depends_on = None + + +def upgrade(): + # Add order_id column + op.add_column( + "calendar_entries", + sa.Column("order_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_calendar_entries_order_id", + "calendar_entries", + "orders", + ["order_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index( + "ix_calendar_entries_order_id", + "calendar_entries", + ["order_id"], + unique=False, + ) + + # Optional: add an index on state if you want faster queries by state + op.create_index( + "ix_calendar_entries_state", + "calendar_entries", + ["state"], + unique=False, + ) + + +def downgrade(): + # Drop indexes and FK in reverse order + op.drop_index("ix_calendar_entries_state", table_name="calendar_entries") + + op.drop_index("ix_calendar_entries_order_id", table_name="calendar_entries") + op.drop_constraint( + "fk_calendar_entries_order_id", + "calendar_entries", + type_="foreignkey", + ) + op.drop_column("calendar_entries", "order_id") diff --git a/alembic/versions/0008_add_flexible_to_slots.py b/alembic/versions/0008_add_flexible_to_slots.py new file mode 100644 index 0000000..0af0cfe --- /dev/null +++ b/alembic/versions/0008_add_flexible_to_slots.py @@ -0,0 +1,33 @@ +"""add flexible flag to calendar_slots + +Revision ID: 0008_add_flexible_to_calendar_slots +Revises: 0007_add_order_id_to_calendar_entries +Create Date: 2025-12-06 12:34:56.000000 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0008_add_flexible_to_slots" +down_revision = "0007_add_oid_entries" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "calendar_slots", + sa.Column( + "flexible", + sa.Boolean(), + nullable=False, + server_default=sa.false(), # set existing rows to False + ), + ) + # Optional: drop server_default so future inserts must supply a value + op.alter_column("calendar_slots", "flexible", server_default=None) + + +def downgrade() -> None: + op.drop_column("calendar_slots", "flexible") diff --git a/alembic/versions/0009_add_slot_id_to_entries.py b/alembic/versions/0009_add_slot_id_to_entries.py new file mode 100644 index 0000000..32c0de4 --- /dev/null +++ b/alembic/versions/0009_add_slot_id_to_entries.py @@ -0,0 +1,54 @@ +"""add slot_id to calendar_entries + +Revision ID: 0009_add_slot_id_to_entries +Revises: 0008_add_flexible_to_slots +Create Date: 2025-12-06 13:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0009_add_slot_id_to_entries" +down_revision = "0008_add_flexible_to_slots" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add slot_id column as nullable initially + op.add_column( + "calendar_entries", + sa.Column( + "slot_id", + sa.Integer(), + nullable=True, + ), + ) + + # Add foreign key constraint + op.create_foreign_key( + "fk_calendar_entries_slot_id_calendar_slots", + "calendar_entries", + "calendar_slots", + ["slot_id"], + ["id"], + ondelete="SET NULL", + ) + + # Add index for better query performance + op.create_index( + "ix_calendar_entries_slot_id", + "calendar_entries", + ["slot_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_calendar_entries_slot_id", table_name="calendar_entries") + op.drop_constraint( + "fk_calendar_entries_slot_id_calendar_slots", + "calendar_entries", + type_="foreignkey", + ) + op.drop_column("calendar_entries", "slot_id") \ No newline at end of file diff --git a/alembic/versions/0010_add_post_likes.py b/alembic/versions/0010_add_post_likes.py new file mode 100644 index 0000000..17bc15b --- /dev/null +++ b/alembic/versions/0010_add_post_likes.py @@ -0,0 +1,64 @@ +"""Add post_likes table for liking blog posts + +Revision ID: 0010_add_post_likes +Revises: 0009_add_slot_id_to_entries +Create Date: 2025-12-07 13:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "0010_add_post_likes" +down_revision = "0009_add_slot_id_to_entries" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "post_likes", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "post_id", + sa.Integer(), + sa.ForeignKey("posts.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + # Index for fast user+post lookups + op.create_index( + "ix_post_likes_user_post", + "post_likes", + ["user_id", "post_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_post_likes_user_post", table_name="post_likes") + op.drop_table("post_likes") diff --git a/alembic/versions/0011_add_entry_tickets.py b/alembic/versions/0011_add_entry_tickets.py new file mode 100644 index 0000000..4b5936f --- /dev/null +++ b/alembic/versions/0011_add_entry_tickets.py @@ -0,0 +1,43 @@ +"""Add ticket_price and ticket_count to calendar_entries + +Revision ID: 0011_add_entry_tickets +Revises: 0010_add_post_likes +Create Date: 2025-12-07 14:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import NUMERIC + +# revision identifiers, used by Alembic. +revision = "0011_add_entry_tickets" +down_revision = "0010_add_post_likes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ticket_price column (nullable - NULL means no tickets) + op.add_column( + "calendar_entries", + sa.Column( + "ticket_price", + NUMERIC(10, 2), + nullable=True, + ), + ) + + # Add ticket_count column (nullable - NULL means unlimited) + op.add_column( + "calendar_entries", + sa.Column( + "ticket_count", + sa.Integer(), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("calendar_entries", "ticket_count") + op.drop_column("calendar_entries", "ticket_price") diff --git a/alembic/versions/47fc53fc0d2b_add_ticket_types_table.py b/alembic/versions/47fc53fc0d2b_add_ticket_types_table.py new file mode 100644 index 0000000..4c3cd5a --- /dev/null +++ b/alembic/versions/47fc53fc0d2b_add_ticket_types_table.py @@ -0,0 +1,41 @@ + +# Alembic migration script template + +"""add ticket_types table + +Revision ID: 47fc53fc0d2b +Revises: a9f54e4eaf02 +Create Date: 2025-12-08 07:29:11.422435 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '47fc53fc0d2b' +down_revision = 'a9f54e4eaf02' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + 'ticket_types', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entry_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['entry_id'], ['calendar_entries.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_ticket_types_entry_id', 'ticket_types', ['entry_id'], unique=False) + op.create_index('ix_ticket_types_name', 'ticket_types', ['name'], unique=False) + +def downgrade() -> None: + op.drop_index('ix_ticket_types_name', table_name='ticket_types') + op.drop_index('ix_ticket_types_entry_id', table_name='ticket_types') + op.drop_table('ticket_types') diff --git a/alembic/versions/6cb124491c9d_entry_posts.py b/alembic/versions/6cb124491c9d_entry_posts.py new file mode 100644 index 0000000..6062096 --- /dev/null +++ b/alembic/versions/6cb124491c9d_entry_posts.py @@ -0,0 +1,36 @@ + +# Alembic migration script template + +"""Add calendar_entry_posts association table + +Revision ID: 6cb124491c9d +Revises: 0011_add_entry_tickets +Create Date: 2025-12-07 03:40:49.194068 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import TIMESTAMP + +# revision identifiers, used by Alembic. +revision = '6cb124491c9d' +down_revision = '0011_add_entry_tickets' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + 'calendar_entry_posts', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('entry_id', sa.Integer(), sa.ForeignKey('calendar_entries.id', ondelete='CASCADE'), nullable=False), + sa.Column('post_id', sa.Integer(), sa.ForeignKey('posts.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_at', TIMESTAMP(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('deleted_at', TIMESTAMP(timezone=True), nullable=True), + ) + op.create_index('ix_entry_posts_entry_id', 'calendar_entry_posts', ['entry_id']) + op.create_index('ix_entry_posts_post_id', 'calendar_entry_posts', ['post_id']) + +def downgrade() -> None: + op.drop_index('ix_entry_posts_post_id', 'calendar_entry_posts') + op.drop_index('ix_entry_posts_entry_id', 'calendar_entry_posts') + op.drop_table('calendar_entry_posts') diff --git a/alembic/versions/a9f54e4eaf02_add_menu_items_table.py b/alembic/versions/a9f54e4eaf02_add_menu_items_table.py new file mode 100644 index 0000000..960c10c --- /dev/null +++ b/alembic/versions/a9f54e4eaf02_add_menu_items_table.py @@ -0,0 +1,37 @@ + +# Alembic migration script template + +"""add menu_items table + +Revision ID: a9f54e4eaf02 +Revises: 6cb124491c9d +Create Date: 2025-12-07 17:38:54.839296 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'a9f54e4eaf02' +down_revision = '6cb124491c9d' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table('menu_items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('post_id', sa.Integer(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_menu_items_post_id'), 'menu_items', ['post_id'], unique=False) + op.create_index(op.f('ix_menu_items_sort_order'), 'menu_items', ['sort_order'], unique=False) + +def downgrade() -> None: + op.drop_index(op.f('ix_menu_items_sort_order'), table_name='menu_items') + op.drop_index(op.f('ix_menu_items_post_id'), table_name='menu_items') + op.drop_table('menu_items') diff --git a/alembic/versions/c3a1f7b9d4e5_add_snippets_table.py b/alembic/versions/c3a1f7b9d4e5_add_snippets_table.py new file mode 100644 index 0000000..c17c08c --- /dev/null +++ b/alembic/versions/c3a1f7b9d4e5_add_snippets_table.py @@ -0,0 +1,35 @@ +"""add snippets table + +Revision ID: c3a1f7b9d4e5 +Revises: 47fc53fc0d2b +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'c3a1f7b9d4e5' +down_revision = '47fc53fc0d2b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'snippets', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('value', sa.Text(), nullable=False), + sa.Column('visibility', sa.String(length=20), server_default='private', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'name', name='uq_snippets_user_name'), + ) + op.create_index('ix_snippets_visibility', 'snippets', ['visibility']) + + +def downgrade() -> None: + op.drop_index('ix_snippets_visibility', table_name='snippets') + op.drop_table('snippets') diff --git a/alembic/versions/d4b2e8f1a3c7_add_post_user_id_and_author_email.py b/alembic/versions/d4b2e8f1a3c7_add_post_user_id_and_author_email.py new file mode 100644 index 0000000..8d6f122 --- /dev/null +++ b/alembic/versions/d4b2e8f1a3c7_add_post_user_id_and_author_email.py @@ -0,0 +1,45 @@ +"""add post user_id, author email, publish_requested + +Revision ID: d4b2e8f1a3c7 +Revises: c3a1f7b9d4e5 +Create Date: 2026-02-08 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'd4b2e8f1a3c7' +down_revision = 'c3a1f7b9d4e5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add author.email + op.add_column('authors', sa.Column('email', sa.String(255), nullable=True)) + + # Add post.user_id FK + op.add_column('posts', sa.Column('user_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_posts_user_id', 'posts', 'users', ['user_id'], ['id'], ondelete='SET NULL') + op.create_index('ix_posts_user_id', 'posts', ['user_id']) + + # Add post.publish_requested + op.add_column('posts', sa.Column('publish_requested', sa.Boolean(), server_default='false', nullable=False)) + + # Backfill: match posts to users via primary_author email + op.execute(""" + UPDATE posts + SET user_id = u.id + FROM authors a + JOIN users u ON lower(a.email) = lower(u.email) + WHERE posts.primary_author_id = a.id + AND posts.user_id IS NULL + AND a.email IS NOT NULL + """) + + +def downgrade() -> None: + op.drop_column('posts', 'publish_requested') + op.drop_index('ix_posts_user_id', table_name='posts') + op.drop_constraint('fk_posts_user_id', 'posts', type_='foreignkey') + op.drop_column('posts', 'user_id') + op.drop_column('authors', 'email') diff --git a/alembic/versions/e5c3f9a2b1d6_add_tag_groups_and_tag_group_tags.py b/alembic/versions/e5c3f9a2b1d6_add_tag_groups_and_tag_group_tags.py new file mode 100644 index 0000000..5e21e22 --- /dev/null +++ b/alembic/versions/e5c3f9a2b1d6_add_tag_groups_and_tag_group_tags.py @@ -0,0 +1,45 @@ +"""add tag_groups and tag_group_tags + +Revision ID: e5c3f9a2b1d6 +Revises: d4b2e8f1a3c7 +Create Date: 2026-02-08 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'e5c3f9a2b1d6' +down_revision = 'd4b2e8f1a3c7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'tag_groups', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=191), nullable=False), + sa.Column('feature_image', sa.Text(), nullable=True), + sa.Column('colour', sa.String(length=32), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug'), + ) + + op.create_table( + 'tag_group_tags', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('tag_group_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['tag_group_id'], ['tag_groups.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tag_group_id', 'tag_id', name='uq_tag_group_tag'), + ) + + +def downgrade() -> None: + op.drop_table('tag_group_tags') + op.drop_table('tag_groups') diff --git a/alembic/versions/f6d4a1b2c3e7_add_tickets_table.py b/alembic/versions/f6d4a1b2c3e7_add_tickets_table.py new file mode 100644 index 0000000..06a0f76 --- /dev/null +++ b/alembic/versions/f6d4a1b2c3e7_add_tickets_table.py @@ -0,0 +1,47 @@ +"""add tickets table + +Revision ID: f6d4a1b2c3e7 +Revises: e5c3f9a2b1d6 +Create Date: 2026-02-09 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'f6d4a1b2c3e7' +down_revision = 'e5c3f9a2b1d6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'tickets', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('entry_id', sa.Integer(), sa.ForeignKey('calendar_entries.id', ondelete='CASCADE'), nullable=False), + sa.Column('ticket_type_id', sa.Integer(), sa.ForeignKey('ticket_types.id', ondelete='SET NULL'), nullable=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('session_id', sa.String(64), nullable=True), + sa.Column('order_id', sa.Integer(), sa.ForeignKey('orders.id', ondelete='SET NULL'), nullable=True), + sa.Column('code', sa.String(64), unique=True, nullable=False), + sa.Column('state', sa.String(20), nullable=False, server_default=sa.text("'reserved'")), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('checked_in_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_index('ix_tickets_entry_id', 'tickets', ['entry_id']) + op.create_index('ix_tickets_ticket_type_id', 'tickets', ['ticket_type_id']) + op.create_index('ix_tickets_user_id', 'tickets', ['user_id']) + op.create_index('ix_tickets_session_id', 'tickets', ['session_id']) + op.create_index('ix_tickets_order_id', 'tickets', ['order_id']) + op.create_index('ix_tickets_code', 'tickets', ['code'], unique=True) + op.create_index('ix_tickets_state', 'tickets', ['state']) + + +def downgrade() -> None: + op.drop_index('ix_tickets_state', 'tickets') + op.drop_index('ix_tickets_code', 'tickets') + op.drop_index('ix_tickets_order_id', 'tickets') + op.drop_index('ix_tickets_session_id', 'tickets') + op.drop_index('ix_tickets_user_id', 'tickets') + op.drop_index('ix_tickets_ticket_type_id', 'tickets') + op.drop_index('ix_tickets_entry_id', 'tickets') + op.drop_table('tickets') diff --git a/config.py b/config.py new file mode 100644 index 0000000..edee631 --- /dev/null +++ b/config.py @@ -0,0 +1,84 @@ +# suma_browser/config.py +from __future__ import annotations + +import asyncio +import os +from types import MappingProxyType +from typing import Any, Optional +import copy +import yaml + +# Default config path (override with APP_CONFIG_FILE) +_DEFAULT_CONFIG_PATH = os.environ.get( + "APP_CONFIG_FILE", + os.path.join(os.getcwd(), "config/app-config.yaml"), +) + +# Module state +_init_lock = asyncio.Lock() +_data_frozen: Any = None # read-only view (mappingproxy / tuples / frozensets) +_data_plain: Any = None # plain builtins for pretty-print / logging + +# ---------------- utils ---------------- +def _freeze(obj: Any) -> Any: + """Deep-freeze containers to read-only equivalents.""" + if isinstance(obj, dict): + # freeze children first, then wrap dict in mappingproxy + return MappingProxyType({k: _freeze(v) for k, v in obj.items()}) + if isinstance(obj, list): + return tuple(_freeze(v) for v in obj) + if isinstance(obj, set): + return frozenset(_freeze(v) for v in obj) + if isinstance(obj, tuple): + return tuple(_freeze(v) for v in obj) + return obj + +# ---------------- API ---------------- +async def init_config(path: Optional[str] = None, *, force: bool = False) -> None: + """ + Load YAML exactly as-is and cache both a frozen (read-only) and a plain copy. + Idempotent; pass force=True to reload. + """ + global _data_frozen, _data_plain + + if _data_frozen is not None and not force: + return + + async with _init_lock: + if _data_frozen is not None and not force: + return + + cfg_path = path or _DEFAULT_CONFIG_PATH + if not os.path.exists(cfg_path): + raise FileNotFoundError(f"Config file not found: {cfg_path}") + + with open(cfg_path, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) # whatever the YAML root is + + # store plain as loaded; store frozen for normal use + _data_plain = raw + _data_frozen = _freeze(raw) + +def config() -> Any: + """ + Return the read-only (frozen) config. Call init_config() first. + """ + if _data_frozen is None: + raise RuntimeError("init_config() has not been awaited yet.") + return _data_frozen + +def as_plain() -> Any: + """ + Return a deep copy of the plain config for safe external use/pretty printing. + """ + if _data_plain is None: + raise RuntimeError("init_config() has not been awaited yet.") + return copy.deepcopy(_data_plain) + +def pretty() -> str: + """ + YAML pretty string without mappingproxy noise. + """ + if _data_plain is None: + raise RuntimeError("init_config() has not been awaited yet.") + return yaml.safe_dump(_data_plain, sort_keys=False, allow_unicode=True) diff --git a/config/app-config.yaml b/config/app-config.yaml new file mode 100644 index 0000000..227cc2e --- /dev/null +++ b/config/app-config.yaml @@ -0,0 +1,83 @@ +# App-wide settings +base_host: "wholesale.suma.coop" +base_login: https://wholesale.suma.coop/customer/account/login/ +base_url: https://wholesale.suma.coop/ +title: Rose Ash +coop_root: /market +coop_title: Market +blog_root: / +blog_title: all the news +cart_root: /cart +app_urls: + coop: "http://localhost:8000" + market: "http://localhost:8001" + cart: "http://localhost:8002" + events: "http://localhost:8003" +cache: + fs_root: _snapshot # <- absolute path to your snapshot dir +categories: + allow: + Basics: basics + Branded Goods: branded-goods + Chilled: chilled + Frozen: frozen + Non-foods: non-foods + Supplements: supplements + Christmas: christmas +slugs: + skip: + - "" + - customer + - account + - checkout + - wishlist + - sales + - contact + - privacy-policy + - terms-and-conditions + - delivery + - catalogsearch + - quickorder + - apply + - search + - static + - media +section-titles: + - ingredients + - allergy information + - allergens + - nutritional information + - nutrition + - storage + - directions + - preparation + - serving suggestions + - origin + - country of origin + - recycling + - general information + - additional information + - a note about prices + +blacklist: + category: + - branded-goods/alcoholic-drinks + - branded-goods/beers + - branded-goods/wines + - branded-goods/ciders + product: + - list-price-suma-current-suma-price-list-each-bk012-2-html + - ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html + product-details: + - General Information + - A Note About Prices + +# SumUp payment settings (fill these in for live usage) +sumup: + merchant_code: "ME4J6100" + currency: "GBP" + # Name of the environment variable that holds your SumUp API key + api_key_env: "SUMUP_API_KEY" + webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING" + checkout_reference_prefix: 'dev-' + diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/base.py b/db/base.py new file mode 100644 index 0000000..e070835 --- /dev/null +++ b/db/base.py @@ -0,0 +1,4 @@ +from __future__ import annotations +from sqlalchemy.orm import declarative_base + +Base = declarative_base() diff --git a/db/session.py b/db/session.py new file mode 100644 index 0000000..a1233ba --- /dev/null +++ b/db/session.py @@ -0,0 +1,99 @@ +from __future__ import annotations +import os +from contextlib import asynccontextmanager +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from quart import Quart, g + +DATABASE_URL = ( + os.getenv("DATABASE_URL_ASYNC") + or os.getenv("DATABASE_URL") + or "postgresql+asyncpg://localhost/coop" +) + +_engine = create_async_engine( + DATABASE_URL, + future=True, + echo=False, + pool_pre_ping=True, + pool_size=-1 # ned to look at this!!! +) + +_Session = async_sessionmaker( + bind=_engine, + class_=AsyncSession, + expire_on_commit=False, +) + +@asynccontextmanager +async def get_session(): + """Always create a fresh AsyncSession for this block.""" + sess = _Session() + try: + yield sess + finally: + await sess.close() + + + +def register_db(app: Quart): + #@app.before_request + #async def _open_session(): + # g.s = _Session() + # g.tx = await g.s.begin() # begin txn now (or begin_nested if you like) + + #@app.after_request + #async def _commit_session(response): + # print('after request') + # return response + + #@app.teardown_request + #async def _rollback_on_error(exc): + # print('teardown') + # # Quart calls this when an exception happened + # if exc is not None and hasattr(g, "tx"): + # await g.tx.rollback() + # if exc and hasattr(g, 'tx'): + # await g.tx.commit() + # if hasattr(g, "sess"): + # await g.s.close() + + + + @app.before_request + async def open_session(): + g.s = _Session() + g.tx = await g.s.begin() + g.had_error = False + + @app.after_request + async def maybe_commit(response): + # Runs BEFORE bytes are sent. + if not g.had_error and 200 <= response.status_code < 400: + try: + if hasattr(g, "tx"): + await g.tx.commit() + except Exception as e: + print(f'commit failed {e}') + if hasattr(g, "tx"): + await g.tx.rollback() + from quart import make_response + return await make_response("Commit failed", 500) + return response + + @app.teardown_request + async def finish(exc): + try: + # If an exception occurred OR we didn’t commit (still in txn), roll back. + if hasattr(g, "s"): + if exc is not None or g.s.in_transaction(): + if hasattr(g, "tx"): + await g.tx.rollback() + finally: + if hasattr(g, "s"): + await g.s.close() + + @app.errorhandler(Exception) + async def mark_error(e): + g.had_error = True + raise + diff --git a/editor/build.mjs b/editor/build.mjs new file mode 100644 index 0000000..13f4cb3 --- /dev/null +++ b/editor/build.mjs @@ -0,0 +1,45 @@ +import * as esbuild from "esbuild"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const isProduction = process.env.NODE_ENV === "production"; +const isWatch = process.argv.includes("--watch"); + +/** @type {import('esbuild').BuildOptions} */ +const opts = { + alias: { + "koenig-styles": path.resolve( + __dirname, + "node_modules/@tryghost/koenig-lexical/dist/index.css" + ), + }, + entryPoints: ["src/index.jsx"], + bundle: true, + outdir: "../static/scripts", + entryNames: "editor", + format: "iife", + target: "es2020", + jsx: "automatic", + minify: isProduction, + define: { + "process.env.NODE_ENV": JSON.stringify( + isProduction ? "production" : "development" + ), + }, + loader: { + ".svg": "dataurl", + ".woff": "file", + ".woff2": "file", + ".ttf": "file", + }, + logLevel: "info", +}; + +if (isWatch) { + const ctx = await esbuild.context(opts); + await ctx.watch(); + console.log("Watching for changes..."); +} else { + await esbuild.build(opts); +} diff --git a/editor/package-lock.json b/editor/package-lock.json new file mode 100644 index 0000000..e102c57 --- /dev/null +++ b/editor/package-lock.json @@ -0,0 +1,512 @@ +{ + "name": "coop-lexical-editor", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "coop-lexical-editor", + "version": "2.0.0", + "dependencies": { + "@tryghost/koenig-lexical": "^1.7.10", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "esbuild": "^0.24.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tryghost/koenig-lexical": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@tryghost/koenig-lexical/-/koenig-lexical-1.7.10.tgz", + "integrity": "sha512-6tI2kbSzZ669hQ5GxpENB8n2aDLugZDmpR/nO0GriduOZJLLN8AdDDa/S3Y8dpF5/cOGKsOxFRj3oLGRDOi6tw==" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + } + } +} diff --git a/editor/package.json b/editor/package.json new file mode 100644 index 0000000..4d556f1 --- /dev/null +++ b/editor/package.json @@ -0,0 +1,18 @@ +{ + "name": "coop-lexical-editor", + "version": "2.0.0", + "private": true, + "scripts": { + "build": "node build.mjs", + "build:prod": "NODE_ENV=production node build.mjs", + "dev": "node build.mjs --watch" + }, + "dependencies": { + "@tryghost/koenig-lexical": "^1.7.10", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "esbuild": "^0.24.0" + } +} diff --git a/editor/src/Editor.jsx b/editor/src/Editor.jsx new file mode 100644 index 0000000..9c01093 --- /dev/null +++ b/editor/src/Editor.jsx @@ -0,0 +1,81 @@ +import { useMemo, useState, useEffect, useCallback } from "react"; +import { KoenigComposer, KoenigEditor, CardMenuPlugin } from "@tryghost/koenig-lexical"; +import "koenig-styles"; +import makeFileUploader from "./useFileUpload"; + +export default function Editor({ initialState, onChange, csrfToken, uploadUrls, oembedUrl, unsplashApiKey, snippetsUrl }) { + const fileUploader = useMemo(() => makeFileUploader(csrfToken, uploadUrls), [csrfToken, uploadUrls]); + + const [snippets, setSnippets] = useState([]); + + useEffect(() => { + if (!snippetsUrl) return; + fetch(snippetsUrl, { headers: { "X-CSRFToken": csrfToken || "" } }) + .then((r) => r.ok ? r.json() : []) + .then(setSnippets) + .catch(() => {}); + }, [snippetsUrl, csrfToken]); + + const createSnippet = useCallback(async ({ name, value }) => { + if (!snippetsUrl) return; + const resp = await fetch(snippetsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken || "", + }, + body: JSON.stringify({ name, value: JSON.stringify(value) }), + }); + if (!resp.ok) return; + const created = await resp.json(); + setSnippets((prev) => { + const idx = prev.findIndex((s) => s.name === created.name); + if (idx >= 0) { + const next = [...prev]; + next[idx] = created; + return next; + } + return [...prev, created].sort((a, b) => a.name.localeCompare(b.name)); + }); + }, [snippetsUrl, csrfToken]); + + const cardConfig = useMemo(() => ({ + fetchEmbed: async (url, { type } = {}) => { + const params = new URLSearchParams({ url }); + if (type) params.set("type", type); + const resp = await fetch(`${oembedUrl}?${params}`, { + headers: { "X-CSRFToken": csrfToken || "" }, + }); + if (!resp.ok) return {}; + return resp.json(); + }, + unsplash: unsplashApiKey + ? { defaultHeaders: { Authorization: `Client-ID ${unsplashApiKey}` } } + : false, + membersEnabled: true, + snippets: snippets.map((s) => ({ + id: s.id, + name: s.name, + value: typeof s.value === "string" ? JSON.parse(s.value) : s.value, + })), + createSnippet, + }), [oembedUrl, csrfToken, unsplashApiKey, snippets, createSnippet]); + + return ( + + { + if (onChange) { + onChange(JSON.stringify(serializedState)); + } + }} + > + + + + ); +} diff --git a/editor/src/index.jsx b/editor/src/index.jsx new file mode 100644 index 0000000..ec1e950 --- /dev/null +++ b/editor/src/index.jsx @@ -0,0 +1,49 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import Editor from "./Editor"; + +/** + * Mount the Koenig editor into the given DOM element. + * + * @param {string} elementId - ID of the container element + * @param {object} opts + * @param {string} [opts.initialJson] - Serialised Lexical JSON (from Ghost) + * @param {string} [opts.csrfToken] - CSRF token for API calls + * @param {object} [opts.uploadUrls] - { image, media, file } upload endpoint URLs + * @param {string} [opts.oembedUrl] - oEmbed proxy endpoint URL + * @param {string} [opts.unsplashApiKey] - Unsplash API key for image search + */ +window.mountEditor = function mountEditor(elementId, opts = {}) { + const container = document.getElementById(elementId); + if (!container) { + console.error(`[editor] Element #${elementId} not found`); + return; + } + + let currentJson = opts.initialJson || null; + + function handleChange(json) { + currentJson = json; + // Stash the latest JSON in a hidden input for form submission + const hidden = document.getElementById("lexical-json-input"); + if (hidden) hidden.value = json; + } + + const root = createRoot(container); + root.render( + + ); + + // Return handle for programmatic access + return { + getJson: () => currentJson, + }; +}; diff --git a/editor/src/useFileUpload.js b/editor/src/useFileUpload.js new file mode 100644 index 0000000..014b8b5 --- /dev/null +++ b/editor/src/useFileUpload.js @@ -0,0 +1,99 @@ +import { useState, useCallback, useRef } from "react"; + +/** + * Koenig expects `fileUploader.useFileUpload(type)` — a React hook it + * calls internally for each card type ("image", "audio", "file", etc.). + * + * `makeFileUploader(csrfToken, uploadUrls)` returns the object Koenig wants: + * { useFileUpload: (type) => { upload, progress, isLoading, errors, filesNumber } } + * + * `uploadUrls` is an object: { image, media, file } + * For backwards compat, a plain string is treated as the image URL. + */ + +const URL_KEY_MAP = { + image: { urlKey: "image", responseKey: "images" }, + audio: { urlKey: "media", responseKey: "media" }, + video: { urlKey: "media", responseKey: "media" }, + mediaThumbnail: { urlKey: "image", responseKey: "images" }, + file: { urlKey: "file", responseKey: "files" }, +}; + +export default function makeFileUploader(csrfToken, uploadUrls) { + // Normalise: string → object with all keys pointing to same URL + const urls = + typeof uploadUrls === "string" + ? { image: uploadUrls, media: uploadUrls, file: uploadUrls } + : uploadUrls || {}; + + return { + fileTypes: { + image: { mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'] }, + audio: { mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp4', 'audio/aac'] }, + video: { mimeTypes: ['video/mp4', 'video/webm', 'video/ogg'] }, + mediaThumbnail: { mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] }, + file: { mimeTypes: [] }, + }, + useFileUpload(type) { + const mapping = URL_KEY_MAP[type] || URL_KEY_MAP.image; + const [progress, setProgress] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState([]); + const [filesNumber, setFilesNumber] = useState(0); + const csrfRef = useRef(csrfToken); + const urlRef = useRef(urls[mapping.urlKey] || urls.image || "/editor-api/images/upload/"); + const responseKeyRef = useRef(mapping.responseKey); + + const upload = useCallback(async (files) => { + const fileList = Array.from(files); + setFilesNumber(fileList.length); + setIsLoading(true); + setErrors([]); + setProgress(0); + + const results = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + const formData = new FormData(); + formData.append("file", file); + + try { + const resp = await fetch(urlRef.current, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": csrfRef.current || "", + }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + const msg = + err.errors?.[0]?.message || `Upload failed (${resp.status})`; + setErrors((prev) => [ + ...prev, + { message: msg, fileName: file.name }, + ]); + continue; + } + const data = await resp.json(); + const fileUrl = data[responseKeyRef.current]?.[0]?.url; + if (fileUrl) { + results.push({ url: fileUrl, fileName: file.name }); + } + } catch (e) { + setErrors((prev) => [ + ...prev, + { message: e.message, fileName: file.name }, + ]); + } + setProgress(Math.round(((i + 1) / fileList.length) * 100)); + } + + setIsLoading(false); + return results; + }, []); + + return { upload, progress, isLoading, errors, filesNumber }; + }, + }; +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..533cc57 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,21 @@ +from .kv import KV +from .user import User +from .magic_link import MagicLink +from .market import ProductLike +from .ghost_content import Author, Tag, Post, PostAuthor, PostTag, PostLike +from .menu_item import MenuItem + + +from .ghost_membership_entities import ( + GhostLabel, UserLabel, + GhostNewsletter, UserNewsletter, + GhostTier, GhostSubscription, +) + +from .calendars import Calendar, CalendarEntry, Ticket + + + +from .order import Order, OrderItem +from .snippet import Snippet +from .tag_group import TagGroup, TagGroupTag diff --git a/models/calendars.py b/models/calendars.py new file mode 100644 index 0000000..212694d --- /dev/null +++ b/models/calendars.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +from sqlalchemy import ( + Column, Integer, String, DateTime, ForeignKey, CheckConstraint, + Index, text, Text, Boolean, Time, Numeric +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +# Adjust this import to match where your Base lives +from db.base import Base + +from datetime import datetime, timezone + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + + +class Calendar(Base): + __tablename__ = "calendars" + + id = Column(Integer, primary_key=True) + post_id = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + slug = Column(String(255), nullable=False) + + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + # relationships + post = relationship("Post", back_populates="calendars") + entries = relationship( + "CalendarEntry", + back_populates="calendar", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="CalendarEntry.start_at", + ) + + slots = relationship( + "CalendarSlot", + back_populates="calendar", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="CalendarSlot.time_start", + ) + + # Indexes / constraints (match Alembic migration) + __table_args__ = ( + # Helpful lookups + Index("ix_calendars_post_id", "post_id"), + Index("ix_calendars_name", "name"), + Index("ix_calendars_slug", "slug"), + # Soft-delete-aware uniqueness (PostgreSQL): + # one active calendar per post/slug (case-insensitive) + Index( + "ux_calendars_post_slug_active", + "post_id", + func.lower(slug), + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + +class CalendarEntry(Base): + __tablename__ = "calendar_entries" + + id = Column(Integer, primary_key=True) + calendar_id = Column( + Integer, + ForeignKey("calendars.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # NEW: ownership + order link + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + session_id = Column(String(64), nullable=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True) + + # NEW: slot link + slot_id = Column(Integer, ForeignKey("calendar_slots.id", ondelete="SET NULL"), nullable=True, index=True) + + # details + name = Column(String(255), nullable=False) + start_at = Column(DateTime(timezone=True), nullable=False, index=True) + end_at = Column(DateTime(timezone=True), nullable=True) + + # NEW: booking state + cost + state = Column( + String(20), + nullable=False, + server_default=text("'pending'"), + ) + cost = Column(Numeric(10, 2), nullable=False, server_default=text("10")) + + # Ticket configuration + ticket_price = Column(Numeric(10, 2), nullable=True) # Price per ticket (NULL = no tickets) + ticket_count = Column(Integer, nullable=True) # Total available tickets (NULL = unlimited) + + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + CheckConstraint( + "(end_at IS NULL) OR (end_at >= start_at)", + name="ck_calendar_entries_end_after_start", + ), + Index("ix_calendar_entries_name", "name"), + Index("ix_calendar_entries_start_at", "start_at"), + Index("ix_calendar_entries_user_id", "user_id"), + Index("ix_calendar_entries_session_id", "session_id"), + Index("ix_calendar_entries_state", "state"), + Index("ix_calendar_entries_order_id", "order_id"), + Index("ix_calendar_entries_slot_id", "slot_id"), + ) + + calendar = relationship("Calendar", back_populates="entries") + slot = relationship("CalendarSlot", back_populates="entries", lazy="selectin") + # Optional, but handy: + order = relationship("Order", back_populates="calendar_entries", lazy="selectin") + posts = relationship("CalendarEntryPost", back_populates="entry", cascade="all, delete-orphan") + ticket_types = relationship( + "TicketType", + back_populates="entry", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="TicketType.name", + ) + +DAY_LABELS = [ + ("mon", "Mon"), + ("tue", "Tue"), + ("wed", "Wed"), + ("thu", "Thu"), + ("fri", "Fri"), + ("sat", "Sat"), + ("sun", "Sun"), +] + + +class CalendarSlot(Base): + __tablename__ = "calendar_slots" + + id = Column(Integer, primary_key=True) + calendar_id = Column( + Integer, + ForeignKey("calendars.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + mon = Column(Boolean, nullable=False, default=False) + tue = Column(Boolean, nullable=False, default=False) + wed = Column(Boolean, nullable=False, default=False) + thu = Column(Boolean, nullable=False, default=False) + fri = Column(Boolean, nullable=False, default=False) + sat = Column(Boolean, nullable=False, default=False) + sun = Column(Boolean, nullable=False, default=False) + + # NEW: whether bookings can be made at flexible times within this band + flexible = Column( + Boolean, + nullable=False, + server_default=text("false"), + default=False, + ) + + @property + def days_display(self) -> str: + days = [label for attr, label in DAY_LABELS if getattr(self, attr)] + if len(days) == len(DAY_LABELS): + # all days selected + return "All" # or "All days" if you prefer + return ", ".join(days) if days else "—" + + time_start = Column(Time(timezone=False), nullable=False) + time_end = Column(Time(timezone=False), nullable=False) + + cost = Column(Numeric(10, 2), nullable=True) + + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + CheckConstraint( + "(time_end > time_start)", + name="ck_calendar_slots_time_end_after_start", + ), + Index("ix_calendar_slots_calendar_id", "calendar_id"), + Index("ix_calendar_slots_time_start", "time_start"), + ) + + calendar = relationship("Calendar", back_populates="slots") + entries = relationship("CalendarEntry", back_populates="slot") + + +class TicketType(Base): + __tablename__ = "ticket_types" + + id = Column(Integer, primary_key=True) + entry_id = Column( + Integer, + ForeignKey("calendar_entries.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + name = Column(String(255), nullable=False) + cost = Column(Numeric(10, 2), nullable=False) + count = Column(Integer, nullable=False) + + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + Index("ix_ticket_types_entry_id", "entry_id"), + Index("ix_ticket_types_name", "name"), + ) + + entry = relationship("CalendarEntry", back_populates="ticket_types") + + +class Ticket(Base): + __tablename__ = "tickets" + + id = Column(Integer, primary_key=True) + entry_id = Column( + Integer, + ForeignKey("calendar_entries.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + ticket_type_id = Column( + Integer, + ForeignKey("ticket_types.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + session_id = Column(String(64), nullable=True, index=True) + order_id = Column( + Integer, + ForeignKey("orders.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + code = Column(String(64), unique=True, nullable=False) # QR/barcode value + state = Column( + String(20), + nullable=False, + server_default=text("'reserved'"), + ) # reserved, confirmed, checked_in, cancelled + + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + checked_in_at = Column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + Index("ix_tickets_entry_id", "entry_id"), + Index("ix_tickets_ticket_type_id", "ticket_type_id"), + Index("ix_tickets_user_id", "user_id"), + Index("ix_tickets_session_id", "session_id"), + Index("ix_tickets_order_id", "order_id"), + Index("ix_tickets_code", "code", unique=True), + Index("ix_tickets_state", "state"), + ) + + entry = relationship("CalendarEntry", backref="tickets") + ticket_type = relationship("TicketType", backref="tickets") + order = relationship("Order", backref="tickets") + + +class CalendarEntryPost(Base): + __tablename__ = "calendar_entry_posts" + + id = Column(Integer, primary_key=True, autoincrement=True) + entry_id = Column(Integer, ForeignKey("calendar_entries.id", ondelete="CASCADE"), nullable=False) + post_id = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False) + + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + Index("ix_entry_posts_entry_id", "entry_id"), + Index("ix_entry_posts_post_id", "post_id"), + ) + + entry = relationship("CalendarEntry", back_populates="posts") + post = relationship("Post", back_populates="calendar_entries") + + +__all__ = ["Calendar", "CalendarEntry", "CalendarSlot", "TicketType", "Ticket", "CalendarEntryPost"] diff --git a/models/cart_item.py b/models/cart_item.py new file mode 100644 index 0000000..728a747 --- /dev/null +++ b/models/cart_item.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Integer, String, DateTime, ForeignKey, func, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db.base import Base # you already import Base in app.py +# from .user import User # only if you normally import it here +# from .coop import Product # if not already in this module + +from .market import Product + +from .user import User + +class CartItem(Base): + __tablename__ = "cart_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # Either a logged-in user OR an anonymous session + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, + ) + session_id: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + ) + + # IMPORTANT: link to product *id*, not slug + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), + nullable=False, + ) + + quantity: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1, + server_default="1", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + # Relationships + + product: Mapped["Product"] = relationship( + "Product", + back_populates="cart_items", + ) + user: Mapped["User | None"] = relationship("User", back_populates="cart_items") + + __table_args__ = ( + Index("ix_cart_items_user_product", "user_id", "product_id"), + Index("ix_cart_items_session_product", "session_id", "product_id"), + ) diff --git a/models/ghost_content.py b/models/ghost_content.py new file mode 100644 index 0000000..be915a9 --- /dev/null +++ b/models/ghost_content.py @@ -0,0 +1,239 @@ +from datetime import datetime +from typing import List, Optional +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ( + Integer, + String, + Text, + Boolean, + DateTime, + ForeignKey, + Column, + func, +) +from db.base import Base # whatever your Base is +# from .author import Author # make sure imports resolve +# from ..app.blog.calendars.model import Calendar + +class Tag(Base): + __tablename__ = "tags" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False) + + slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + + description: Mapped[Optional[str]] = mapped_column(Text()) + visibility: Mapped[str] = mapped_column(String(32), default="public", nullable=False) + feature_image: Mapped[Optional[str]] = mapped_column(Text()) + + meta_title: Mapped[Optional[str]] = mapped_column(String(300)) + meta_description: Mapped[Optional[str]] = mapped_column(Text()) + + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # NEW: posts relationship is now direct Post objects via PostTag + posts: Mapped[List["Post"]] = relationship( + "Post", + secondary="post_tags", + primaryjoin="Tag.id==post_tags.c.tag_id", + secondaryjoin="Post.id==post_tags.c.post_id", + back_populates="tags", + order_by="PostTag.sort_order", + ) + + +class Post(Base): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False) + uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False) + + title: Mapped[str] = mapped_column(String(500), nullable=False) + + html: Mapped[Optional[str]] = mapped_column(Text()) + plaintext: Mapped[Optional[str]] = mapped_column(Text()) + mobiledoc: Mapped[Optional[str]] = mapped_column(Text()) + lexical: Mapped[Optional[str]] = mapped_column(Text()) + + feature_image: Mapped[Optional[str]] = mapped_column(Text()) + feature_image_alt: Mapped[Optional[str]] = mapped_column(Text()) + feature_image_caption: Mapped[Optional[str]] = mapped_column(Text()) + + excerpt: Mapped[Optional[str]] = mapped_column(Text()) + custom_excerpt: Mapped[Optional[str]] = mapped_column(Text()) + + visibility: Mapped[str] = mapped_column(String(32), default="public", nullable=False) + status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False) + featured: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) + is_page: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) + email_only: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False) + + canonical_url: Mapped[Optional[str]] = mapped_column(Text()) + meta_title: Mapped[Optional[str]] = mapped_column(String(500)) + meta_description: Mapped[Optional[str]] = mapped_column(Text()) + og_image: Mapped[Optional[str]] = mapped_column(Text()) + og_title: Mapped[Optional[str]] = mapped_column(String(500)) + og_description: Mapped[Optional[str]] = mapped_column(Text()) + twitter_image: Mapped[Optional[str]] = mapped_column(Text()) + twitter_title: Mapped[Optional[str]] = mapped_column(String(500)) + twitter_description: Mapped[Optional[str]] = mapped_column(Text()) + custom_template: Mapped[Optional[str]] = mapped_column(String(191)) + + reading_time: Mapped[Optional[int]] = mapped_column(Integer()) + comment_id: Mapped[Optional[str]] = mapped_column(String(191)) + + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + publish_requested: Mapped[bool] = mapped_column(Boolean(), default=False, server_default="false", nullable=False) + + primary_author_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("authors.id", ondelete="SET NULL") + ) + primary_tag_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("tags.id", ondelete="SET NULL") + ) + + primary_author: Mapped[Optional["Author"]] = relationship( + "Author", foreign_keys=[primary_author_id] + ) + primary_tag: Mapped[Optional[Tag]] = relationship( + "Tag", foreign_keys=[primary_tag_id] + ) + user: Mapped[Optional["User"]] = relationship( + "User", foreign_keys=[user_id] + ) + + # AUTHORS RELATIONSHIP (many-to-many via post_authors) + authors: Mapped[List["Author"]] = relationship( + "Author", + secondary="post_authors", + primaryjoin="Post.id==post_authors.c.post_id", + secondaryjoin="Author.id==post_authors.c.author_id", + back_populates="posts", + order_by="PostAuthor.sort_order", + ) + + # TAGS RELATIONSHIP (many-to-many via post_tags) + tags: Mapped[List[Tag]] = relationship( + "Tag", + secondary="post_tags", + primaryjoin="Post.id==post_tags.c.post_id", + secondaryjoin="Tag.id==post_tags.c.tag_id", + back_populates="posts", + order_by="PostTag.sort_order", + ) + calendars:Mapped[List["Calendar"]] = relationship( + "Calendar", + back_populates="post", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="Calendar.name", + ) + + likes: Mapped[List["PostLike"]] = relationship( + "PostLike", + back_populates="post", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + calendar_entries: Mapped[List["CalendarEntryPost"]] = relationship( + "CalendarEntryPost", + back_populates="post", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + menu_items: Mapped[List["MenuItem"]] = relationship( + "MenuItem", + back_populates="post", + cascade="all, delete-orphan", + passive_deletes=True, + order_by="MenuItem.sort_order", + ) + +class Author(Base): + __tablename__ = "authors" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False) + + slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[Optional[str]] = mapped_column(String(255)) + + profile_image: Mapped[Optional[str]] = mapped_column(Text()) + cover_image: Mapped[Optional[str]] = mapped_column(Text()) + bio: Mapped[Optional[str]] = mapped_column(Text()) + website: Mapped[Optional[str]] = mapped_column(Text()) + location: Mapped[Optional[str]] = mapped_column(Text()) + facebook: Mapped[Optional[str]] = mapped_column(Text()) + twitter: Mapped[Optional[str]] = mapped_column(Text()) + + created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # backref to posts via post_authors + posts: Mapped[List[Post]] = relationship( + "Post", + secondary="post_authors", + primaryjoin="Author.id==post_authors.c.author_id", + secondaryjoin="Post.id==post_authors.c.post_id", + back_populates="authors", + order_by="PostAuthor.sort_order", + ) + +class PostAuthor(Base): + __tablename__ = "post_authors" + + post_id: Mapped[int] = mapped_column( + ForeignKey("posts.id", ondelete="CASCADE"), + primary_key=True, + ) + author_id: Mapped[int] = mapped_column( + ForeignKey("authors.id", ondelete="CASCADE"), + primary_key=True, + ) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + +class PostTag(Base): + __tablename__ = "post_tags" + + post_id: Mapped[int] = mapped_column( + ForeignKey("posts.id", ondelete="CASCADE"), + primary_key=True, + ) + tag_id: Mapped[int] = mapped_column( + ForeignKey("tags.id", ondelete="CASCADE"), + primary_key=True, + ) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + +class PostLike(Base): + __tablename__ = "post_likes" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + post: Mapped["Post"] = relationship("Post", back_populates="likes", foreign_keys=[post_id]) + user = relationship("User", back_populates="liked_posts") diff --git a/models/ghost_membership_entities.py b/models/ghost_membership_entities.py new file mode 100644 index 0000000..50e1c71 --- /dev/null +++ b/models/ghost_membership_entities.py @@ -0,0 +1,122 @@ +# suma_browser/models/ghost_membership_entities.py + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Integer, String, Text, Boolean, DateTime, ForeignKey, UniqueConstraint +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.ext.associationproxy import association_proxy + +from db.base import Base + + +# ----------------------- +# Labels (simple M2M) +# ----------------------- + +class GhostLabel(Base): + __tablename__ = "ghost_labels" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[Optional[str]] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + + # Back-populated by User.labels + users = relationship("User", secondary="user_labels", back_populates="labels", lazy="selectin") + + +class UserLabel(Base): + __tablename__ = "user_labels" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + label_id: Mapped[int] = mapped_column(ForeignKey("ghost_labels.id", ondelete="CASCADE"), index=True) + + __table_args__ = ( + UniqueConstraint("user_id", "label_id", name="uq_user_label"), + ) + + +# ----------------------- +# Newsletters (association object + proxy) +# ----------------------- + +class GhostNewsletter(Base): + __tablename__ = "ghost_newsletters" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[Optional[str]] = mapped_column(String(255)) + description: Mapped[Optional[str]] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + + # Association-object side (one-to-many) + user_newsletters = relationship( + "UserNewsletter", + back_populates="newsletter", + cascade="all, delete-orphan", + lazy="selectin", + ) + + # Convenience: list-like proxy of Users via association rows (read-only container) + users = association_proxy("user_newsletters", "user") + + +class UserNewsletter(Base): + __tablename__ = "user_newsletters" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + newsletter_id: Mapped[int] = mapped_column(ForeignKey("ghost_newsletters.id", ondelete="CASCADE"), index=True) + subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + __table_args__ = ( + UniqueConstraint("user_id", "newsletter_id", name="uq_user_newsletter"), + ) + + # Bidirectional links for the association object + user = relationship("User", back_populates="user_newsletters", lazy="selectin") + newsletter = relationship("GhostNewsletter", back_populates="user_newsletters", lazy="selectin") + + +# ----------------------- +# Tiers & Subscriptions +# ----------------------- + +class GhostTier(Base): + __tablename__ = "ghost_tiers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[Optional[str]] = mapped_column(String(255)) + type: Mapped[Optional[str]] = mapped_column(String(50)) # e.g. free, paid + visibility: Mapped[Optional[str]] = mapped_column(String(50)) + + +class GhostSubscription(Base): + __tablename__ = "ghost_subscriptions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + ghost_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + status: Mapped[Optional[str]] = mapped_column(String(50)) + tier_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ghost_tiers.id", ondelete="SET NULL"), index=True) + cadence: Mapped[Optional[str]] = mapped_column(String(50)) # month, year + price_amount: Mapped[Optional[int]] = mapped_column(Integer) + price_currency: Mapped[Optional[str]] = mapped_column(String(10)) + stripe_customer_id: Mapped[Optional[str]] = mapped_column(String(255), index=True) + stripe_subscription_id: Mapped[Optional[str]] = mapped_column(String(255), index=True) + raw: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + + # Relationships + user = relationship("User", back_populates="subscriptions", lazy="selectin") + tier = relationship("GhostTier", lazy="selectin") diff --git a/models/kv.py b/models/kv.py new file mode 100644 index 0000000..98032d5 --- /dev/null +++ b/models/kv.py @@ -0,0 +1,13 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import String, Text, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from db.base import Base + +class KV(Base): + __tablename__ = "kv" + """Simple key-value table for settings/cache/demo.""" + key: Mapped[str] = mapped_column(String(120), primary_key=True) + value: Mapped[str | None] = mapped_column(Text(), nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + \ No newline at end of file diff --git a/models/magic_link.py b/models/magic_link.py new file mode 100644 index 0000000..011d344 --- /dev/null +++ b/models/magic_link.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, ForeignKey, func, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from db.base import Base + +class MagicLink(Base): + __tablename__ = "magic_links" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + token: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + purpose: Mapped[str] = mapped_column(String(32), nullable=False, default="signin") + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(256), nullable=True) + + user = relationship("User", backref="magic_links") + + __table_args__ = ( + Index("ix_magic_link_token", "token", unique=True), + Index("ix_magic_link_user", "user_id"), + ) diff --git a/models/market.py b/models/market.py new file mode 100644 index 0000000..004a3b0 --- /dev/null +++ b/models/market.py @@ -0,0 +1,425 @@ +# at top of persist_snapshot.py: +from datetime import datetime +from typing import Optional, List +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from typing import List, Optional + +from sqlalchemy import ( + String, Text, Integer, ForeignKey, DateTime, Boolean, Numeric, + UniqueConstraint, Index, func +) + +from db.base import Base # you already import Base in app.py + + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + + title: Mapped[Optional[str]] = mapped_column(String(512)) + image: Mapped[Optional[str]] = mapped_column(Text) + + description_short: Mapped[Optional[str]] = mapped_column(Text) + description_html: Mapped[Optional[str]] = mapped_column(Text) + + suma_href: Mapped[Optional[str]] = mapped_column(Text) + brand: Mapped[Optional[str]] = mapped_column(String(255)) + + rrp: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + rrp_currency: Mapped[Optional[str]] = mapped_column(String(16)) + rrp_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + price_per_unit: Mapped[Optional[float]] = mapped_column(Numeric(12, 4)) + price_per_unit_currency: Mapped[Optional[str]] = mapped_column(String(16)) + price_per_unit_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + special_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + special_price_currency: Mapped[Optional[str]] = mapped_column(String(16)) + special_price_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + regular_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + regular_price_currency: Mapped[Optional[str]] = mapped_column(String(16)) + regular_price_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + oe_list_price: Mapped[Optional[float]] = mapped_column(Numeric(12, 2)) + + case_size_count: Mapped[Optional[int]] = mapped_column(Integer) + case_size_item_qty: Mapped[Optional[float]] = mapped_column(Numeric(12, 3)) + case_size_item_unit: Mapped[Optional[str]] = mapped_column(String(32)) + case_size_raw: Mapped[Optional[str]] = mapped_column(String(128)) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + images: Mapped[List["ProductImage"]] = relationship( + back_populates="product", + cascade="all, delete-orphan", + passive_deletes=True, + ) + sections: Mapped[List["ProductSection"]] = relationship( + back_populates="product", + cascade="all, delete-orphan", + passive_deletes=True, + ) + labels: Mapped[List["ProductLabel"]] = relationship( + cascade="all, delete-orphan", + passive_deletes=True, + ) + stickers: Mapped[List["ProductSticker"]] = relationship( + cascade="all, delete-orphan", + passive_deletes=True, + ) + + ean: Mapped[Optional[str]] = mapped_column(String(64)) + sku: Mapped[Optional[str]] = mapped_column(String(128)) + unit_size: Mapped[Optional[str]] = mapped_column(String(128)) + pack_size: Mapped[Optional[str]] = mapped_column(String(128)) + + attributes = relationship( + "ProductAttribute", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + nutrition = relationship( + "ProductNutrition", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + allergens = relationship( + "ProductAllergen", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + + likes = relationship( + "ProductLike", + back_populates="product", + cascade="all, delete-orphan", + ) + cart_items: Mapped[List["CartItem"]] = relationship( + "CartItem", + back_populates="product", + cascade="all, delete-orphan", + ) + + # NEW: all order items that reference this product + order_items: Mapped[List["OrderItem"]] = relationship( + "OrderItem", + back_populates="product", + cascade="all, delete-orphan", + ) + +from sqlalchemy import Column + +class ProductLike(Base): + __tablename__ = "product_likes" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + product_slug: Mapped[str] = mapped_column(ForeignKey("products.slug", ondelete="CASCADE")) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + product: Mapped["Product"] = relationship("Product", back_populates="likes", foreign_keys=[product_slug]) + + user = relationship("User", back_populates="liked_products") # optional, if you want reverse access + + +class ProductImage(Base): + __tablename__ = "product_images" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + url: Mapped[str] = mapped_column(Text, nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + kind: Mapped[str] = mapped_column(String(16), nullable=False, default="gallery") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + product: Mapped["Product"] = relationship(back_populates="images") + + __table_args__ = ( + UniqueConstraint("product_id", "url", "kind", name="uq_product_images_product_url_kind"), + Index("ix_product_images_position", "position"), + ) + +class ProductSection(Base): + __tablename__ = "product_sections" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + html: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # ⬇️ ADD THIS LINE: + product: Mapped["Product"] = relationship(back_populates="sections") + __table_args__ = ( + UniqueConstraint("product_id", "title", name="uq_product_sections_product_title"), + ) +# --- Nav & listings --- + +class NavTop(Base): + __tablename__ = "nav_tops" + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + listings: Mapped[List["Listing"]] = relationship(back_populates="top", cascade="all, delete-orphan") + + __table_args__ = (UniqueConstraint("label", "slug", name="uq_nav_tops_label_slug"),) + +class NavSub(Base): + __tablename__ = "nav_subs" + id: Mapped[int] = mapped_column(primary_key=True) + top_id: Mapped[int] = mapped_column(ForeignKey("nav_tops.id", ondelete="CASCADE"), index=True, nullable=False) + label: Mapped[Optional[str]] = mapped_column(String(255)) + slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + href: Mapped[Optional[str]] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + listings: Mapped[List["Listing"]] = relationship(back_populates="sub", cascade="all, delete-orphan") + + __table_args__ = (UniqueConstraint("top_id", "slug", name="uq_nav_subs_top_slug"),) + +class Listing(Base): + __tablename__ = "listings" + + id: Mapped[int] = mapped_column(primary_key=True) + + # Old slug-based fields (optional: remove) + # top_slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + # sub_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + + top_id: Mapped[int] = mapped_column(ForeignKey("nav_tops.id", ondelete="CASCADE"), index=True, nullable=False) + sub_id: Mapped[Optional[int]] = mapped_column(ForeignKey("nav_subs.id", ondelete="CASCADE"), index=True) + + total_pages: Mapped[Optional[int]] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + + top: Mapped["NavTop"] = relationship(back_populates="listings") + sub: Mapped[Optional["NavSub"]] = relationship(back_populates="listings") + + __table_args__ = ( + UniqueConstraint("top_id", "sub_id", name="uq_listings_top_sub"), + ) + +class ListingItem(Base): + __tablename__ = "listing_items" + id: Mapped[int] = mapped_column(primary_key=True) + listing_id: Mapped[int] = mapped_column(ForeignKey("listings.id", ondelete="CASCADE"), index=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + __table_args__ = (UniqueConstraint("listing_id", "slug", name="uq_listing_items_listing_slug"),) + +# --- Reports / redirects / logs --- + +class LinkError(Base): + __tablename__ = "link_errors" + id: Mapped[int] = mapped_column(primary_key=True) + product_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + href: Mapped[Optional[str]] = mapped_column(Text) + text: Mapped[Optional[str]] = mapped_column(Text) + top: Mapped[Optional[str]] = mapped_column(String(255)) + sub: Mapped[Optional[str]] = mapped_column(String(255)) + target_slug: Mapped[Optional[str]] = mapped_column(String(255)) + type: Mapped[Optional[str]] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + +class LinkExternal(Base): + __tablename__ = "link_externals" + id: Mapped[int] = mapped_column(primary_key=True) + product_slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + href: Mapped[Optional[str]] = mapped_column(Text) + text: Mapped[Optional[str]] = mapped_column(Text) + host: Mapped[Optional[str]] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + +class SubcategoryRedirect(Base): + __tablename__ = "subcategory_redirects" + id: Mapped[int] = mapped_column(primary_key=True) + old_path: Mapped[str] = mapped_column(String(512), nullable=False, index=True) + new_path: Mapped[str] = mapped_column(String(512), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + +class ProductLog(Base): + __tablename__ = "product_logs" + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[Optional[str]] = mapped_column(String(255), index=True) + href_tried: Mapped[Optional[str]] = mapped_column(Text) + ok: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + error_type: Mapped[Optional[str]] = mapped_column(String(255)) + error_message: Mapped[Optional[str]] = mapped_column(Text) + http_status: Mapped[Optional[int]] = mapped_column(Integer) + final_url: Mapped[Optional[str]] = mapped_column(Text) + transport_error: Mapped[Optional[bool]] = mapped_column(Boolean) + title: Mapped[Optional[str]] = mapped_column(String(512)) + has_description_html: Mapped[Optional[bool]] = mapped_column(Boolean) + has_description_short: Mapped[Optional[bool]] = mapped_column(Boolean) + sections_count: Mapped[Optional[int]] = mapped_column(Integer) + images_count: Mapped[Optional[int]] = mapped_column(Integer) + embedded_images_count: Mapped[Optional[int]] = mapped_column(Integer) + all_images_count: Mapped[Optional[int]] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + + +# ...existing models... + +class ProductLabel(Base): + __tablename__ = "product_labels" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + product: Mapped["Product"] = relationship(back_populates="labels") + + __table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_labels_product_name"),) + +class ProductSticker(Base): + __tablename__ = "product_stickers" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + product: Mapped["Product"] = relationship(back_populates="stickers") + + __table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_stickers_product_name"),) + +class ProductAttribute(Base): + __tablename__ = "product_attributes" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + key: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + value: Mapped[Optional[str]] = mapped_column(Text) + product = relationship("Product", back_populates="attributes") + __table_args__ = (UniqueConstraint("product_id", "key", name="uq_product_attributes_product_key"),) + +class ProductNutrition(Base): + __tablename__ = "product_nutrition" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[Optional[str]] = mapped_column(String(255)) + unit: Mapped[Optional[str]] = mapped_column(String(64)) + product = relationship("Product", back_populates="nutrition") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + __table_args__ = (UniqueConstraint("product_id", "key", name="uq_product_nutrition_product_key"),) + +class ProductAllergen(Base): + __tablename__ = "product_allergens" + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + contains: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + product: Mapped["Product"] = relationship(back_populates="allergens") + __table_args__ = (UniqueConstraint("product_id", "name", name="uq_product_allergens_product_name"),) + + +class CartItem(Base): + __tablename__ = "cart_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # Either a logged-in user OR an anonymous session + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, + ) + session_id: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + ) + + # IMPORTANT: link to product *id*, not slug + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), + nullable=False, + ) + + quantity: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1, + server_default="1", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + # Relationships + + product: Mapped["Product"] = relationship( + "Product", + back_populates="cart_items", + ) + user: Mapped["User | None"] = relationship("User", back_populates="cart_items") + + __table_args__ = ( + Index("ix_cart_items_user_product", "user_id", "product_id"), + Index("ix_cart_items_session_product", "session_id", "product_id"), + ) diff --git a/models/menu_item.py b/models/menu_item.py new file mode 100644 index 0000000..949ab59 --- /dev/null +++ b/models/menu_item.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import Integer, String, DateTime, ForeignKey, func +from db.base import Base + + +class MenuItem(Base): + __tablename__ = "menu_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # Foreign key to posts table + post_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("posts.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + + # Order for sorting menu items + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False + ) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True + ) + + # Relationship to Post + post: Mapped["Post"] = relationship("Post", back_populates="menu_items") diff --git a/models/order.py b/models/order.py new file mode 100644 index 0000000..6534ca1 --- /dev/null +++ b/models/order.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional, List + +from sqlalchemy import Integer, String, DateTime, ForeignKey, Numeric, func, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db.base import Base + + +class Order(Base): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True) + session_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True) + + status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="pending", + server_default="pending", + ) + currency: Mapped[str] = mapped_column(String(16), nullable=False, default="GBP") + total_amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False) + + # free-form description for the order + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, index=True) + + # SumUp reference string (what we send as checkout_reference) + sumup_reference: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + index=True, + ) + + # SumUp integration fields + sumup_checkout_id: Mapped[Optional[str]] = mapped_column( + String(128), + nullable=True, + index=True, + ) + sumup_status: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + sumup_hosted_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + items: Mapped[List["OrderItem"]] = relationship( + "OrderItem", + back_populates="order", + cascade="all, delete-orphan", + lazy="selectin", + ) + calendar_entries: Mapped[List["CalendarEntry"]] = relationship( + "CalendarEntry", + back_populates="order", + lazy="selectin", + ) + + +class OrderItem(Base): + __tablename__ = "order_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_id: Mapped[int] = mapped_column( + ForeignKey("orders.id", ondelete="CASCADE"), + nullable=False, + ) + + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id"), + nullable=False, + ) + product_title: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(16), nullable=False, default="GBP") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + order: Mapped["Order"] = relationship( + "Order", + back_populates="items", + ) + + # NEW: link each order item to its product + product: Mapped["Product"] = relationship( + "Product", + back_populates="order_items", + lazy="selectin", + ) diff --git a/models/snippet.py b/models/snippet.py new file mode 100644 index 0000000..5b4a306 --- /dev/null +++ b/models/snippet.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func +from sqlalchemy.orm import Mapped, mapped_column + +from db.base import Base + + +class Snippet(Base): + __tablename__ = "snippets" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_snippets_user_name"), + Index("ix_snippets_visibility", "visibility"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False) + visibility: Mapped[str] = mapped_column( + String(20), nullable=False, default="private", server_default="private", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), + ) diff --git a/models/tag_group.py b/models/tag_group.py new file mode 100644 index 0000000..5dd4746 --- /dev/null +++ b/models/tag_group.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import List, Optional +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ( + Integer, + String, + Text, + DateTime, + ForeignKey, + UniqueConstraint, + func, +) +from db.base import Base + + +class TagGroup(Base): + __tablename__ = "tag_groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False) + feature_image: Mapped[Optional[str]] = mapped_column(Text()) + colour: Mapped[Optional[str]] = mapped_column(String(32)) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + tag_links: Mapped[List["TagGroupTag"]] = relationship( + "TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True + ) + + +class TagGroupTag(Base): + __tablename__ = "tag_group_tags" + __table_args__ = ( + UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tag_group_id: Mapped[int] = mapped_column( + ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False + ) + tag_id: Mapped[int] = mapped_column( + ForeignKey("tags.id", ondelete="CASCADE"), nullable=False + ) + + group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links") diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..52c99c3 --- /dev/null +++ b/models/user.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, func, Index, Text, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.ext.associationproxy import association_proxy +from db.base import Base + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # Ghost membership linkage + ghost_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + ghost_status: Mapped[str | None] = mapped_column(String(50), nullable=True) # free, paid, comped + ghost_subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=func.true()) + ghost_note: Mapped[str | None] = mapped_column(Text, nullable=True) + avatar_image: Mapped[str | None] = mapped_column(Text, nullable=True) + stripe_customer_id: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True) + ghost_raw: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + # Relationships to Ghost-related entities + + user_newsletters = relationship("UserNewsletter", back_populates="user", cascade="all, delete-orphan", lazy="selectin") + newsletters = association_proxy("user_newsletters", "newsletter") + labels = relationship("GhostLabel", secondary="user_labels", back_populates="users", lazy="selectin") + subscriptions = relationship("GhostSubscription", back_populates="user", cascade="all, delete-orphan", lazy="selectin") + + liked_products = relationship("ProductLike", back_populates="user", cascade="all, delete-orphan") + liked_posts = relationship("PostLike", back_populates="user", cascade="all, delete-orphan") + cart_items = relationship( + "CartItem", + back_populates="user", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + Index("ix_user_email", "email", unique=True), + ) + + def __repr__(self) -> str: + return f"" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6672849 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,47 @@ +starlette>=0.37,<0.39 +aiofiles==25.1.0 +aiosmtplib==5.0.0 +alembic==1.17.0 +anyio==4.11.0 +async-timeout==5.0.1 +asyncpg==0.30.0 +beautifulsoup4==4.14.2 +blinker==1.9.0 +Brotli==1.1.0 +certifi==2025.10.5 +click==8.3.0 +exceptiongroup==1.3.0 +Flask==3.1.2 +greenlet==3.2.4 +h11==0.16.0 +h2==4.3.0 +hpack==4.1.0 +httpcore==1.0.9 +httpx==0.28.1 +Hypercorn==0.17.3 +hyperframe==6.1.0 +idna==3.10 +itsdangerous==2.2.0 +Jinja2==3.1.6 +lxml==6.0.2 +Mako==1.3.10 +MarkupSafe==3.0.3 +priority==2.0.0 +psycopg==3.2.11 +psycopg-binary==3.2.11 +PyJWT==2.10.1 +PyYAML==6.0.3 +Quart==0.20.0 +sniffio==1.3.1 +soupsieve==2.8 +SQLAlchemy==2.0.44 +taskgroup==0.2.2 +tomli==2.3.0 +typing_extensions==4.15.0 +Werkzeug==3.1.3 +wsproto==1.2.0 +zstandard==0.25.0 +redis>=5.0 +mistune>=3.0 +pytest>=8.0 +pytest-asyncio>=0.23 diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/cart_identity.py b/shared/cart_identity.py new file mode 100644 index 0000000..f16c674 --- /dev/null +++ b/shared/cart_identity.py @@ -0,0 +1,34 @@ +""" +Cart identity resolution — shared across all apps that need to know +who the current cart owner is (user_id or anonymous session_id). +""" +from __future__ import annotations + +import secrets +from typing import TypedDict, Optional + +from quart import g, session as qsession + + +class CartIdentity(TypedDict): + user_id: Optional[int] + session_id: Optional[str] + + +def current_cart_identity() -> CartIdentity: + """ + Decide how to identify the cart: + + - If user is logged in -> use user_id (and ignore session_id) + - Else -> generate / reuse an anonymous session_id stored in Quart's session + """ + user = getattr(g, "user", None) + if user is not None and getattr(user, "id", None) is not None: + return {"user_id": user.id, "session_id": None} + + sid = qsession.get("cart_sid") + if not sid: + sid = secrets.token_hex(16) + qsession["cart_sid"] = sid + + return {"user_id": None, "session_id": sid} diff --git a/shared/cart_loader.py b/shared/cart_loader.py new file mode 100644 index 0000000..996683f --- /dev/null +++ b/shared/cart_loader.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from quart import g + +from suma_browser.app.bp.cart.services import get_cart + + +async def load_cart(): + g.cart = await get_cart(g.s) diff --git a/shared/context.py b/shared/context.py new file mode 100644 index 0000000..6320174 --- /dev/null +++ b/shared/context.py @@ -0,0 +1,58 @@ +""" +Base template context shared by all apps. + +This module no longer imports cart or menu_items services directly. +Each app provides its own context_fn that calls this base and adds +app-specific variables (cart data, menu_items, etc.). +""" +from __future__ import annotations + +from datetime import datetime + +from quart import request, g, current_app + +from config import config +from utils import host_url +from suma_browser.app.utils import current_route_relative_path + + +async def base_context() -> dict: + """ + Common template variables available in every app. + + Does NOT include cart, calendar_cart_entries, total, calendar_total, + or menu_items — those are added by each app's context_fn. + """ + is_htmx = request.headers.get("HX-Request") == "true" + search = request.headers.get("X-Search", "") + zap_filter = is_htmx and search == "" + + def base_url(): + return host_url() + + hx_select = "#main-panel" + hx_select_search = ( + hx_select + + ", #search-mobile, #search-count-mobile, #search-desktop, #search-count-desktop, #menu-items-nav-wrapper" + ) + + return { + "is_htmx": is_htmx, + "request": request, + "now": datetime.now(), + "current_local_href": current_route_relative_path(), + "config": config(), + "asset_url": current_app.jinja_env.globals.get("asset_url", lambda p: ""), + "sort_options": [ + ("az", "A\u2013Z", "order/a-z.svg"), + ("za", "Z\u2013A", "order/z-a.svg"), + ("price-asc", "\u00a3 low\u2192high", "order/l-h.svg"), + ("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"), + ], + "zap_filter": zap_filter, + "print": print, + "base_url": base_url, + "base_title": config()["title"], + "hx_select": hx_select, + "hx_select_search": hx_select_search, + } diff --git a/shared/factory.py b/shared/factory.py new file mode 100644 index 0000000..6c4b090 --- /dev/null +++ b/shared/factory.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import Callable, Awaitable, Sequence + +from quart import Quart, request, g, send_from_directory + +from config import init_config, config, pretty +from models import KV # ensure models imported + +from db.session import register_db +from suma_browser.app.middleware import register as register_middleware +from suma_browser.app.redis_cacher import register as register_redis +from suma_browser.app.csrf import protect +from suma_browser.app.errors import errors + +from .jinja_setup import setup_jinja +from .user_loader import load_current_user + + +# Async init of config (runs once at import) +asyncio.run(init_config()) + +BASE_DIR = Path(__file__).resolve().parent.parent +STATIC_DIR = str(BASE_DIR / "static") +TEMPLATE_DIR = str(BASE_DIR / "suma_browser" / "templates") + + +def create_base_app( + name: str, + *, + context_fn: Callable[[], Awaitable[dict]] | None = None, + before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None, +) -> Quart: + """ + Create a Quart app with shared infrastructure. + + Parameters + ---------- + name: + Application name (also used as CACHE_APP_PREFIX). + context_fn: + Async function returning a dict for template context. + Each app provides its own — the cart app queries locally, + while coop/market apps fetch via internal API. + If not provided, a minimal default context is used. + before_request_fns: + Extra before-request hooks (e.g. cart_loader for the cart app). + """ + app = Quart( + name, + static_folder=STATIC_DIR, + static_url_path="/static", + template_folder=TEMPLATE_DIR, + ) + + app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-me-777") + + # Session cookie shared across subdomains + cookie_domain = os.getenv("SESSION_COOKIE_DOMAIN") # e.g. ".rose-ash.com" + if cookie_domain: + app.config["SESSION_COOKIE_DOMAIN"] = cookie_domain + app.config["SESSION_COOKIE_NAME"] = "coop_session" + + # Ghost / Redis config + app.config["GHOST_API_URL"] = os.getenv("GHOST_API_URL") + app.config["GHOST_PUBLIC_URL"] = os.getenv("GHOST_PUBLIC_URL") + app.config["GHOST_CONTENT_KEY"] = os.getenv("GHOST_CONTENT_API_KEY") + app.config["REDIS_URL"] = os.getenv("REDIS_URL") + + # Cache app prefix for key namespacing + app.config["CACHE_APP_PREFIX"] = name + + # --- infrastructure --- + register_middleware(app) + register_db(app) + register_redis(app) + setup_jinja(app) + errors(app) + + # --- before-request hooks --- + @app.before_request + async def _route_log(): + g.root = request.headers.get("x-forwarded-prefix", "/") + g.scheme = request.scheme + g.host = request.host + + @app.before_request + async def _load_user(): + await load_current_user() + + # Register any app-specific before-request hooks (e.g. cart loader) + if before_request_fns: + for fn in before_request_fns: + app.before_request(fn) + + @app.before_request + async def _csrf_protect(): + await protect() + + # --- after-request hooks --- + @app.after_request + async def _add_hx_preserve_search_header(response): + value = request.headers.get("X-Search") + if value is not None: + response.headers["HX-Preserve-Search"] = value + return response + + # --- context processor --- + if context_fn is not None: + @app.context_processor + async def _inject_base(): + return await context_fn() + else: + # Minimal fallback (no cart, no menu_items) + from .context import base_context + + @app.context_processor + async def _inject_base(): + return await base_context() + + # --- cleanup internal API client on shutdown --- + @app.after_serving + async def _close_internal_client(): + from .internal_api import close_client + await close_client() + + # --- startup --- + @app.before_serving + async def _startup(): + await init_config() + print(pretty()) + + # --- favicon --- + @app.get("/favicon.ico") + async def favicon(): + return await send_from_directory("static", "favicon.ico") + + return app diff --git a/shared/http_utils.py b/shared/http_utils.py new file mode 100644 index 0000000..cce6195 --- /dev/null +++ b/shared/http_utils.py @@ -0,0 +1,49 @@ +""" +HTTP utility helpers shared across apps. + +Extracted from browse/services/services.py so order/orders blueprints +(which live in the cart app) don't need to import from the browse blueprint. +""" +from __future__ import annotations + +from urllib.parse import urlencode + +from quart import g, request +from utils import host_url + + +def vary(resp): + """ + Ensure HX-Request and X-Origin are part of the Vary header + so caches distinguish HTMX from full-page requests. + """ + v = resp.headers.get("Vary", "") + parts = [p.strip() for p in v.split(",") if p.strip()] + for h in ("HX-Request", "X-Origin"): + if h not in parts: + parts.append(h) + if parts: + resp.headers["Vary"] = ", ".join(parts) + return resp + + +def current_url_without_page(): + """ + Return the current URL with the ``page`` query-string parameter removed. + Used for Hx-Push-Url headers on paginated routes. + """ + (request.script_root or "").rstrip("/") + root2 = "/" + g.root + path_only = request.path + + if root2 and path_only.startswith(root2): + rel = path_only[len(root2):] + rel = rel if rel.startswith("/") else "/" + rel + else: + rel = path_only + base = host_url(rel) + + params = request.args.to_dict(flat=False) + params.pop("page", None) + qs = urlencode(params, doseq=True) + return f"{base}?{qs}" if qs else base diff --git a/shared/internal_api.py b/shared/internal_api.py new file mode 100644 index 0000000..1ab6c3d --- /dev/null +++ b/shared/internal_api.py @@ -0,0 +1,152 @@ +""" +Async HTTP client for inter-app communication. + +Each app exposes internal JSON API endpoints. Other apps call them +via httpx over the Docker overlay network (or localhost in dev). + +URLs resolved from env vars: + INTERNAL_URL_COOP (default http://localhost:8000) + INTERNAL_URL_MARKET (default http://localhost:8001) + INTERNAL_URL_CART (default http://localhost:8002) + +Session cookie forwarding: when ``forward_session=True`` the current +request's ``coop_session`` cookie is sent along so the target app can +resolve ``g.user`` / cart identity. +""" +from __future__ import annotations + +import logging +import os +from typing import Any + +import httpx +from quart import request as quart_request + +log = logging.getLogger("internal_api") + +class DictObj: + """Thin wrapper so ``d.key`` works on dicts returned by JSON APIs. + + Jinja templates use attribute access (``item.post.slug``) which + doesn't work on plain dicts. Wrapping the API response with + ``dictobj()`` makes both ``item.post.slug`` and ``item["post"]["slug"]`` + work identically. + """ + + __slots__ = ("_data",) + + def __init__(self, data: dict): + self._data = data + + def __getattr__(self, name: str): + try: + v = self._data[name] + except KeyError: + raise AttributeError(name) + if isinstance(v, dict): + return DictObj(v) + return v + + def get(self, key, default=None): + v = self._data.get(key, default) + if isinstance(v, dict): + return DictObj(v) + return v + + def __repr__(self): + return f"DictObj({self._data!r})" + + def __bool__(self): + return bool(self._data) + + +def dictobj(data): + """Recursively wrap dicts (or lists of dicts) for attribute access.""" + if isinstance(data, list): + return [DictObj(d) if isinstance(d, dict) else d for d in data] + if isinstance(data, dict): + return DictObj(data) + return data + + +_DEFAULTS = { + "coop": "http://localhost:8000", + "market": "http://localhost:8001", + "cart": "http://localhost:8002", + "events": "http://localhost:8003", +} + +_client: httpx.AsyncClient | None = None + +TIMEOUT = 3.0 # seconds + + +def _base_url(app_name: str) -> str: + env_key = f"INTERNAL_URL_{app_name.upper()}" + return os.getenv(env_key, _DEFAULTS.get(app_name, "")) + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient(timeout=TIMEOUT) + return _client + + +async def close_client() -> None: + """Call from ``@app.after_serving`` to cleanly close the pool.""" + global _client + if _client is not None and not _client.is_closed: + await _client.aclose() + _client = None + + +def _session_cookies() -> dict[str, str]: + """Extract the shared session cookie from the incoming request.""" + cookie_name = "coop_session" + try: + val = quart_request.cookies.get(cookie_name) + except RuntimeError: + # No active request context + val = None + if val: + return {cookie_name: val} + return {} + + +async def get( + app_name: str, + path: str, + *, + forward_session: bool = False, + params: dict | None = None, +) -> dict | list | None: + """GET ```` and return parsed JSON, or ``None`` on failure.""" + url = _base_url(app_name).rstrip("/") + path + cookies = _session_cookies() if forward_session else {} + try: + resp = await _get_client().get(url, params=params, cookies=cookies) + resp.raise_for_status() + return resp.json() + except Exception as exc: + log.warning("internal_api GET %s failed: %r", url, exc) + return None + + +async def post( + app_name: str, + path: str, + *, + json: Any = None, + forward_session: bool = False, +) -> dict | list | None: + """POST ```` and return parsed JSON, or ``None`` on failure.""" + url = _base_url(app_name).rstrip("/") + path + cookies = _session_cookies() if forward_session else {} + try: + resp = await _get_client().post(url, json=json, cookies=cookies) + resp.raise_for_status() + return resp.json() + except Exception as exc: + log.warning("internal_api POST %s failed: %r", url, exc) + return None diff --git a/shared/jinja_setup.py b/shared/jinja_setup.py new file mode 100644 index 0000000..2e98c90 --- /dev/null +++ b/shared/jinja_setup.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +from quart import Quart, g, url_for + +from config import config +from utils import host_url + +from suma_browser.app.csrf import generate_csrf_token +from suma_browser.app.authz import has_access +from suma_browser.app.filters import register as register_filters + +from .urls import coop_url, market_url, cart_url, events_url, login_url + + +def setup_jinja(app: Quart) -> None: + app.jinja_env.add_extension("jinja2.ext.do") + + # --- template globals --- + app.add_template_global(generate_csrf_token, "csrf_token") + app.add_template_global(has_access, "has_access") + + def level(): + if not hasattr(g, "_level_counter"): + g._level_counter = 0 + return g._level_counter + + def level_up(): + if not hasattr(g, "_level_counter"): + g._level_counter = 0 + g._level_counter += 1 + return "" + + app.jinja_env.globals["level"] = level + app.jinja_env.globals["level_up"] = level_up + app.jinja_env.globals["menu_colour"] = "sky" + + nav_button = """justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black + [.hover-capable_&]:hover:bg-yellow-300 + aria-selected:bg-stone-500 aria-selected:text-white + [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500""" + + styles = { + "pill": """ + inline-flex items-center px-3 py-1 rounded-full bg-stone-200 text-stone-700 text-sm + hover:bg-stone-300 hover:text-stone-900 + focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 + """, + "tr": "odd:bg-slate-50 even:bg-white hover:bg-slate-100", + "action_button": "px-2 py-1 border rounded text-sm bg-sky-300 hover:bg-sky-400 flex gap-1 items-center", + "pre_action_button": "px-2 py-1 border rounded text-sm bg-green-200 hover:bg-green-300", + "cancel_button": "px-3 py-1.5 rounded-full text-sm border border-stone-300 text-stone-700 hover:bg-stone-100", + "list_container": "border border-stone-200 rounded-lg p-3 mb-3 bg-white space-y-3 bg-yellow-200", + "nav_button": f"{nav_button} p-3", + "nav_button_less_pad": f"{nav_button} p-2", + } + app.jinja_env.globals["styles"] = styles + + def _asset_url(path: str) -> str: + def squash_double_slashes(url: str) -> str: + m = re.match(r"(?:[A-Za-z][\w+.-]*:)?//", url) + prefix = m.group(0) if m else "" + rest = re.sub(r"/+", "/", url[len(prefix):]) + return prefix + rest + + file_path = Path("static") / path + try: + digest = hashlib.md5(file_path.read_bytes()).hexdigest()[:8] + except Exception: + digest = "dev" + return squash_double_slashes( + f"{g.scheme}://{g.host}{g.root}/{url_for('static', filename=path, v=digest)}" + ) + + app.jinja_env.globals["asset_url"] = _asset_url + + def site(): + return { + "url": host_url(), + "logo": _asset_url("img/logo.jpg"), + "default_image": _asset_url("img/logo.jpg"), + "title": config()["title"], + } + + app.jinja_env.globals["site"] = site + + # cross-app URL helpers available in all templates + app.jinja_env.globals["coop_url"] = coop_url + app.jinja_env.globals["market_url"] = market_url + app.jinja_env.globals["cart_url"] = cart_url + app.jinja_env.globals["events_url"] = events_url + app.jinja_env.globals["login_url"] = login_url + + # register jinja filters + register_filters(app) diff --git a/shared/urls.py b/shared/urls.py new file mode 100644 index 0000000..26b055c --- /dev/null +++ b/shared/urls.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +from urllib.parse import quote + +from config import config + + +def _get_app_url(app_name: str) -> str: + env_key = f"APP_URL_{app_name.upper()}" + env_val = os.getenv(env_key) + if env_val: + return env_val.rstrip("/") + return config()["app_urls"][app_name].rstrip("/") + + +def app_url(app_name: str, path: str = "/") -> str: + base = _get_app_url(app_name) + if not path.startswith("/"): + path = "/" + path + return base + path + + +def coop_url(path: str = "/") -> str: + return app_url("coop", path) + + +def market_url(path: str = "/") -> str: + return app_url("market", path) + + +def cart_url(path: str = "/") -> str: + return app_url("cart", path) + + +def events_url(path: str = "/") -> str: + return app_url("events", path) + + +def login_url(next_url: str = "") -> str: + if next_url: + return coop_url(f"/auth/login/?next={quote(next_url, safe='')}") + return coop_url("/auth/login/") diff --git a/shared/user_loader.py b/shared/user_loader.py new file mode 100644 index 0000000..6b6a46b --- /dev/null +++ b/shared/user_loader.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from quart import session as qsession, g +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from models.user import User +from models.ghost_membership_entities import UserNewsletter + + +async def load_user_by_id(session, user_id: int): + """Load a user by ID with labels and newsletters eagerly loaded.""" + stmt = ( + select(User) + .options( + selectinload(User.labels), + selectinload(User.user_newsletters).selectinload( + UserNewsletter.newsletter + ), + ) + .where(User.id == user_id) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def load_current_user(): + uid = qsession.get("uid") + if not uid: + g.user = None + g.rights = {"admin": False} + return + + g.user = await load_user_by_id(g.s, uid) + g.rights = {l.name: True for l in g.user.labels} if g.user else {} diff --git a/static/errors/403.gif b/static/errors/403.gif new file mode 100644 index 0000000..940f6db Binary files /dev/null and b/static/errors/403.gif differ diff --git a/static/errors/404.gif b/static/errors/404.gif new file mode 100644 index 0000000..18d68e3 Binary files /dev/null and b/static/errors/404.gif differ diff --git a/static/errors/error.gif b/static/errors/error.gif new file mode 100644 index 0000000..b8bf54c Binary files /dev/null and b/static/errors/error.gif differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..e1b7520 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/fontawesome/css/all.min.css b/static/fontawesome/css/all.min.css new file mode 100644 index 0000000..cd555f1 --- /dev/null +++ b/static/fontawesome/css/all.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2023 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-pixiv:before{content:"\e640"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-brave:before{content:"\e63c"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-threads:before{content:"\e618"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-opensuse:before{content:"\e62b"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-debian:before{content:"\e60b"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-square-letterboxd:before{content:"\e62e"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-shoelace:before{content:"\e60c"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-square-threads:before{content:"\e619"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-google-scholar:before{content:"\e63b"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-signal-messenger:before{content:"\e663"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-mintbit:before{content:"\e62f"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-brave-reverse:before{content:"\e63d"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-letterboxd:before{content:"\e62d"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-x-twitter:before{content:"\e61b"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-upwork:before{content:"\e641"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-webflow:before{content:"\e65c"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-square-x-twitter:before{content:"\e61a"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/static/fontawesome/css/v4-shims.min.css b/static/fontawesome/css/v4-shims.min.css new file mode 100644 index 0000000..13fa437 --- /dev/null +++ b/static/fontawesome/css/v4-shims.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2023 Fonticons, Inc. + */ +.fa.fa-glass:before{content:"\f000"}.fa.fa-envelope-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-envelope-o:before{content:"\f0e0"}.fa.fa-star-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-o:before{content:"\f005"}.fa.fa-close:before,.fa.fa-remove:before{content:"\f00d"}.fa.fa-gear:before{content:"\f013"}.fa.fa-trash-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-trash-o:before{content:"\f2ed"}.fa.fa-home:before{content:"\f015"}.fa.fa-file-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-o:before{content:"\f15b"}.fa.fa-clock-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-clock-o:before{content:"\f017"}.fa.fa-arrow-circle-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-down:before{content:"\f358"}.fa.fa-arrow-circle-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-up:before{content:"\f35b"}.fa.fa-play-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-play-circle-o:before{content:"\f144"}.fa.fa-repeat:before,.fa.fa-rotate-right:before{content:"\f01e"}.fa.fa-refresh:before{content:"\f021"}.fa.fa-list-alt{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-list-alt:before{content:"\f022"}.fa.fa-dedent:before{content:"\f03b"}.fa.fa-video-camera:before{content:"\f03d"}.fa.fa-picture-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-picture-o:before{content:"\f03e"}.fa.fa-photo{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-photo:before{content:"\f03e"}.fa.fa-image{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-image:before{content:"\f03e"}.fa.fa-map-marker:before{content:"\f3c5"}.fa.fa-pencil-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-pencil-square-o:before{content:"\f044"}.fa.fa-edit{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-edit:before{content:"\f044"}.fa.fa-share-square-o:before{content:"\f14d"}.fa.fa-check-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-check-square-o:before{content:"\f14a"}.fa.fa-arrows:before{content:"\f0b2"}.fa.fa-times-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-times-circle-o:before{content:"\f057"}.fa.fa-check-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-check-circle-o:before{content:"\f058"}.fa.fa-mail-forward:before{content:"\f064"}.fa.fa-expand:before{content:"\f424"}.fa.fa-compress:before{content:"\f422"}.fa.fa-eye,.fa.fa-eye-slash{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-warning:before{content:"\f071"}.fa.fa-calendar:before{content:"\f073"}.fa.fa-arrows-v:before{content:"\f338"}.fa.fa-arrows-h:before{content:"\f337"}.fa.fa-bar-chart-o:before,.fa.fa-bar-chart:before{content:"\e0e3"}.fa.fa-twitter-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-twitter-square:before{content:"\f081"}.fa.fa-facebook-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook-square:before{content:"\f082"}.fa.fa-gears:before{content:"\f085"}.fa.fa-thumbs-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-thumbs-o-up:before{content:"\f164"}.fa.fa-thumbs-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-thumbs-o-down:before{content:"\f165"}.fa.fa-heart-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-heart-o:before{content:"\f004"}.fa.fa-sign-out:before{content:"\f2f5"}.fa.fa-linkedin-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-linkedin-square:before{content:"\f08c"}.fa.fa-thumb-tack:before{content:"\f08d"}.fa.fa-external-link:before{content:"\f35d"}.fa.fa-sign-in:before{content:"\f2f6"}.fa.fa-github-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-github-square:before{content:"\f092"}.fa.fa-lemon-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-lemon-o:before{content:"\f094"}.fa.fa-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-square-o:before{content:"\f0c8"}.fa.fa-bookmark-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-bookmark-o:before{content:"\f02e"}.fa.fa-facebook,.fa.fa-twitter{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook:before{content:"\f39e"}.fa.fa-facebook-f{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook-f:before{content:"\f39e"}.fa.fa-github{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-credit-card{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-feed:before{content:"\f09e"}.fa.fa-hdd-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hdd-o:before{content:"\f0a0"}.fa.fa-hand-o-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-right:before{content:"\f0a4"}.fa.fa-hand-o-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-left:before{content:"\f0a5"}.fa.fa-hand-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-up:before{content:"\f0a6"}.fa.fa-hand-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-o-down:before{content:"\f0a7"}.fa.fa-globe:before{content:"\f57d"}.fa.fa-tasks:before{content:"\f828"}.fa.fa-arrows-alt:before{content:"\f31e"}.fa.fa-group:before{content:"\f0c0"}.fa.fa-chain:before{content:"\f0c1"}.fa.fa-cut:before{content:"\f0c4"}.fa.fa-files-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-files-o:before{content:"\f0c5"}.fa.fa-floppy-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-floppy-o:before{content:"\f0c7"}.fa.fa-save{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-save:before{content:"\f0c7"}.fa.fa-navicon:before,.fa.fa-reorder:before{content:"\f0c9"}.fa.fa-magic:before{content:"\e2ca"}.fa.fa-pinterest,.fa.fa-pinterest-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-pinterest-square:before{content:"\f0d3"}.fa.fa-google-plus-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus-square:before{content:"\f0d4"}.fa.fa-google-plus{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus:before{content:"\f0d5"}.fa.fa-money:before{content:"\f3d1"}.fa.fa-unsorted:before{content:"\f0dc"}.fa.fa-sort-desc:before{content:"\f0dd"}.fa.fa-sort-asc:before{content:"\f0de"}.fa.fa-linkedin{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-linkedin:before{content:"\f0e1"}.fa.fa-rotate-left:before{content:"\f0e2"}.fa.fa-legal:before{content:"\f0e3"}.fa.fa-dashboard:before,.fa.fa-tachometer:before{content:"\f625"}.fa.fa-comment-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-comment-o:before{content:"\f075"}.fa.fa-comments-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-comments-o:before{content:"\f086"}.fa.fa-flash:before{content:"\f0e7"}.fa.fa-clipboard:before{content:"\f0ea"}.fa.fa-lightbulb-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-lightbulb-o:before{content:"\f0eb"}.fa.fa-exchange:before{content:"\f362"}.fa.fa-cloud-download:before{content:"\f0ed"}.fa.fa-cloud-upload:before{content:"\f0ee"}.fa.fa-bell-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-bell-o:before{content:"\f0f3"}.fa.fa-cutlery:before{content:"\f2e7"}.fa.fa-file-text-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-text-o:before{content:"\f15c"}.fa.fa-building-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-building-o:before{content:"\f1ad"}.fa.fa-hospital-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hospital-o:before{content:"\f0f8"}.fa.fa-tablet:before{content:"\f3fa"}.fa.fa-mobile-phone:before,.fa.fa-mobile:before{content:"\f3cd"}.fa.fa-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-circle-o:before{content:"\f111"}.fa.fa-mail-reply:before{content:"\f3e5"}.fa.fa-github-alt{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-folder-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-folder-o:before{content:"\f07b"}.fa.fa-folder-open-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-folder-open-o:before{content:"\f07c"}.fa.fa-smile-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-smile-o:before{content:"\f118"}.fa.fa-frown-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-frown-o:before{content:"\f119"}.fa.fa-meh-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-meh-o:before{content:"\f11a"}.fa.fa-keyboard-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-keyboard-o:before{content:"\f11c"}.fa.fa-flag-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-flag-o:before{content:"\f024"}.fa.fa-mail-reply-all:before{content:"\f122"}.fa.fa-star-half-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-half-o:before{content:"\f5c0"}.fa.fa-star-half-empty{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-half-empty:before{content:"\f5c0"}.fa.fa-star-half-full{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-star-half-full:before{content:"\f5c0"}.fa.fa-code-fork:before{content:"\f126"}.fa.fa-chain-broken:before,.fa.fa-unlink:before{content:"\f127"}.fa.fa-calendar-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-o:before{content:"\f133"}.fa.fa-css3,.fa.fa-html5,.fa.fa-maxcdn{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-unlock-alt:before{content:"\f09c"}.fa.fa-minus-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-minus-square-o:before{content:"\f146"}.fa.fa-level-up:before{content:"\f3bf"}.fa.fa-level-down:before{content:"\f3be"}.fa.fa-pencil-square:before{content:"\f14b"}.fa.fa-external-link-square:before{content:"\f360"}.fa.fa-compass{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-down:before{content:"\f150"}.fa.fa-toggle-down{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-down:before{content:"\f150"}.fa.fa-caret-square-o-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-up:before{content:"\f151"}.fa.fa-toggle-up{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-up:before{content:"\f151"}.fa.fa-caret-square-o-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-right:before{content:"\f152"}.fa.fa-toggle-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-right:before{content:"\f152"}.fa.fa-eur:before,.fa.fa-euro:before{content:"\f153"}.fa.fa-gbp:before{content:"\f154"}.fa.fa-dollar:before,.fa.fa-usd:before{content:"\24"}.fa.fa-inr:before,.fa.fa-rupee:before{content:"\e1bc"}.fa.fa-cny:before,.fa.fa-jpy:before,.fa.fa-rmb:before,.fa.fa-yen:before{content:"\f157"}.fa.fa-rouble:before,.fa.fa-rub:before,.fa.fa-ruble:before{content:"\f158"}.fa.fa-krw:before,.fa.fa-won:before{content:"\f159"}.fa.fa-bitcoin,.fa.fa-btc{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bitcoin:before{content:"\f15a"}.fa.fa-file-text:before{content:"\f15c"}.fa.fa-sort-alpha-asc:before{content:"\f15d"}.fa.fa-sort-alpha-desc:before{content:"\f881"}.fa.fa-sort-amount-asc:before{content:"\f884"}.fa.fa-sort-amount-desc:before{content:"\f160"}.fa.fa-sort-numeric-asc:before{content:"\f162"}.fa.fa-sort-numeric-desc:before{content:"\f886"}.fa.fa-youtube-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-youtube-square:before{content:"\f431"}.fa.fa-xing,.fa.fa-xing-square,.fa.fa-youtube{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-xing-square:before{content:"\f169"}.fa.fa-youtube-play{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-youtube-play:before{content:"\f167"}.fa.fa-adn,.fa.fa-bitbucket,.fa.fa-bitbucket-square,.fa.fa-dropbox,.fa.fa-flickr,.fa.fa-instagram,.fa.fa-stack-overflow{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bitbucket-square:before{content:"\f171"}.fa.fa-tumblr,.fa.fa-tumblr-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-tumblr-square:before{content:"\f174"}.fa.fa-long-arrow-down:before{content:"\f309"}.fa.fa-long-arrow-up:before{content:"\f30c"}.fa.fa-long-arrow-left:before{content:"\f30a"}.fa.fa-long-arrow-right:before{content:"\f30b"}.fa.fa-android,.fa.fa-apple,.fa.fa-dribbble,.fa.fa-foursquare,.fa.fa-gittip,.fa.fa-gratipay,.fa.fa-linux,.fa.fa-skype,.fa.fa-trello,.fa.fa-windows{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-gittip:before{content:"\f184"}.fa.fa-sun-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-sun-o:before{content:"\f185"}.fa.fa-moon-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-moon-o:before{content:"\f186"}.fa.fa-pagelines,.fa.fa-renren,.fa.fa-stack-exchange,.fa.fa-vk,.fa.fa-weibo{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-arrow-circle-o-right{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-right:before{content:"\f35a"}.fa.fa-arrow-circle-o-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-arrow-circle-o-left:before{content:"\f359"}.fa.fa-caret-square-o-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-caret-square-o-left:before{content:"\f191"}.fa.fa-toggle-left{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-toggle-left:before{content:"\f191"}.fa.fa-dot-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-dot-circle-o:before{content:"\f192"}.fa.fa-vimeo-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-vimeo-square:before{content:"\f194"}.fa.fa-try:before,.fa.fa-turkish-lira:before{content:"\e2bb"}.fa.fa-plus-square-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-plus-square-o:before{content:"\f0fe"}.fa.fa-openid,.fa.fa-slack,.fa.fa-wordpress{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bank:before,.fa.fa-institution:before{content:"\f19c"}.fa.fa-mortar-board:before{content:"\f19d"}.fa.fa-google,.fa.fa-reddit,.fa.fa-reddit-square,.fa.fa-yahoo{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-reddit-square:before{content:"\f1a2"}.fa.fa-behance,.fa.fa-behance-square,.fa.fa-delicious,.fa.fa-digg,.fa.fa-drupal,.fa.fa-joomla,.fa.fa-pied-piper-alt,.fa.fa-pied-piper-pp,.fa.fa-stumbleupon,.fa.fa-stumbleupon-circle{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-behance-square:before{content:"\f1b5"}.fa.fa-steam,.fa.fa-steam-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-steam-square:before{content:"\f1b7"}.fa.fa-automobile:before{content:"\f1b9"}.fa.fa-cab:before{content:"\f1ba"}.fa.fa-deviantart,.fa.fa-soundcloud,.fa.fa-spotify{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-file-pdf-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-pdf-o:before{content:"\f1c1"}.fa.fa-file-word-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-word-o:before{content:"\f1c2"}.fa.fa-file-excel-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-excel-o:before{content:"\f1c3"}.fa.fa-file-powerpoint-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-powerpoint-o:before{content:"\f1c4"}.fa.fa-file-image-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-image-o:before{content:"\f1c5"}.fa.fa-file-photo-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-photo-o:before{content:"\f1c5"}.fa.fa-file-picture-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-picture-o:before{content:"\f1c5"}.fa.fa-file-archive-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-archive-o:before{content:"\f1c6"}.fa.fa-file-zip-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-zip-o:before{content:"\f1c6"}.fa.fa-file-audio-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-audio-o:before{content:"\f1c7"}.fa.fa-file-sound-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-sound-o:before{content:"\f1c7"}.fa.fa-file-video-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-video-o:before{content:"\f1c8"}.fa.fa-file-movie-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-movie-o:before{content:"\f1c8"}.fa.fa-file-code-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-file-code-o:before{content:"\f1c9"}.fa.fa-codepen,.fa.fa-jsfiddle,.fa.fa-vine{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-life-bouy:before,.fa.fa-life-buoy:before,.fa.fa-life-saver:before,.fa.fa-support:before{content:"\f1cd"}.fa.fa-circle-o-notch:before{content:"\f1ce"}.fa.fa-ra,.fa.fa-rebel{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-ra:before{content:"\f1d0"}.fa.fa-resistance{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-resistance:before{content:"\f1d0"}.fa.fa-empire,.fa.fa-ge{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-ge:before{content:"\f1d1"}.fa.fa-git-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-git-square:before{content:"\f1d2"}.fa.fa-git,.fa.fa-hacker-news,.fa.fa-y-combinator-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-y-combinator-square:before{content:"\f1d4"}.fa.fa-yc-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-yc-square:before{content:"\f1d4"}.fa.fa-qq,.fa.fa-tencent-weibo,.fa.fa-wechat,.fa.fa-weixin{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-wechat:before{content:"\f1d7"}.fa.fa-send:before{content:"\f1d8"}.fa.fa-paper-plane-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-paper-plane-o:before{content:"\f1d8"}.fa.fa-send-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-send-o:before{content:"\f1d8"}.fa.fa-circle-thin{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-circle-thin:before{content:"\f111"}.fa.fa-header:before{content:"\f1dc"}.fa.fa-futbol-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-futbol-o:before{content:"\f1e3"}.fa.fa-soccer-ball-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-soccer-ball-o:before{content:"\f1e3"}.fa.fa-slideshare,.fa.fa-twitch,.fa.fa-yelp{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-newspaper-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-newspaper-o:before{content:"\f1ea"}.fa.fa-cc-amex,.fa.fa-cc-discover,.fa.fa-cc-mastercard,.fa.fa-cc-paypal,.fa.fa-cc-stripe,.fa.fa-cc-visa,.fa.fa-google-wallet,.fa.fa-paypal{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-bell-slash-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-bell-slash-o:before{content:"\f1f6"}.fa.fa-trash:before{content:"\f2ed"}.fa.fa-copyright{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-eyedropper:before{content:"\f1fb"}.fa.fa-area-chart:before{content:"\f1fe"}.fa.fa-pie-chart:before{content:"\f200"}.fa.fa-line-chart:before{content:"\f201"}.fa.fa-lastfm,.fa.fa-lastfm-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-lastfm-square:before{content:"\f203"}.fa.fa-angellist,.fa.fa-ioxhost{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-cc{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-cc:before{content:"\f20a"}.fa.fa-ils:before,.fa.fa-shekel:before,.fa.fa-sheqel:before{content:"\f20b"}.fa.fa-buysellads,.fa.fa-connectdevelop,.fa.fa-dashcube,.fa.fa-forumbee,.fa.fa-leanpub,.fa.fa-sellsy,.fa.fa-shirtsinbulk,.fa.fa-simplybuilt,.fa.fa-skyatlas{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-diamond{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-diamond:before{content:"\f3a5"}.fa.fa-intersex:before,.fa.fa-transgender:before{content:"\f224"}.fa.fa-transgender-alt:before{content:"\f225"}.fa.fa-facebook-official{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-facebook-official:before{content:"\f09a"}.fa.fa-pinterest-p,.fa.fa-whatsapp{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-hotel:before{content:"\f236"}.fa.fa-medium,.fa.fa-viacoin,.fa.fa-y-combinator,.fa.fa-yc{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-yc:before{content:"\f23b"}.fa.fa-expeditedssl,.fa.fa-opencart,.fa.fa-optin-monster{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-battery-4:before,.fa.fa-battery:before{content:"\f240"}.fa.fa-battery-3:before{content:"\f241"}.fa.fa-battery-2:before{content:"\f242"}.fa.fa-battery-1:before{content:"\f243"}.fa.fa-battery-0:before{content:"\f244"}.fa.fa-object-group,.fa.fa-object-ungroup,.fa.fa-sticky-note-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-sticky-note-o:before{content:"\f249"}.fa.fa-cc-diners-club,.fa.fa-cc-jcb{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-clone{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hourglass-o:before{content:"\f254"}.fa.fa-hourglass-1:before{content:"\f251"}.fa.fa-hourglass-2:before{content:"\f252"}.fa.fa-hourglass-3:before{content:"\f253"}.fa.fa-hand-rock-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-rock-o:before{content:"\f255"}.fa.fa-hand-grab-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-grab-o:before{content:"\f255"}.fa.fa-hand-paper-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-paper-o:before{content:"\f256"}.fa.fa-hand-stop-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-stop-o:before{content:"\f256"}.fa.fa-hand-scissors-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-scissors-o:before{content:"\f257"}.fa.fa-hand-lizard-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-lizard-o:before{content:"\f258"}.fa.fa-hand-spock-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-spock-o:before{content:"\f259"}.fa.fa-hand-pointer-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-pointer-o:before{content:"\f25a"}.fa.fa-hand-peace-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-hand-peace-o:before{content:"\f25b"}.fa.fa-registered{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-creative-commons,.fa.fa-gg,.fa.fa-gg-circle,.fa.fa-odnoklassniki,.fa.fa-odnoklassniki-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-odnoklassniki-square:before{content:"\f264"}.fa.fa-chrome,.fa.fa-firefox,.fa.fa-get-pocket,.fa.fa-internet-explorer,.fa.fa-opera,.fa.fa-safari,.fa.fa-wikipedia-w{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-television:before{content:"\f26c"}.fa.fa-500px,.fa.fa-amazon,.fa.fa-contao{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-calendar-plus-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-plus-o:before{content:"\f271"}.fa.fa-calendar-minus-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-minus-o:before{content:"\f272"}.fa.fa-calendar-times-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-times-o:before{content:"\f273"}.fa.fa-calendar-check-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-calendar-check-o:before{content:"\f274"}.fa.fa-map-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-map-o:before{content:"\f279"}.fa.fa-commenting:before{content:"\f4ad"}.fa.fa-commenting-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-commenting-o:before{content:"\f4ad"}.fa.fa-houzz,.fa.fa-vimeo{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-vimeo:before{content:"\f27d"}.fa.fa-black-tie,.fa.fa-edge,.fa.fa-fonticons,.fa.fa-reddit-alien{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-credit-card-alt:before{content:"\f09d"}.fa.fa-codiepie,.fa.fa-fort-awesome,.fa.fa-mixcloud,.fa.fa-modx,.fa.fa-product-hunt,.fa.fa-scribd,.fa.fa-usb{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-pause-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-pause-circle-o:before{content:"\f28b"}.fa.fa-stop-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-stop-circle-o:before{content:"\f28d"}.fa.fa-bluetooth,.fa.fa-bluetooth-b,.fa.fa-envira,.fa.fa-gitlab,.fa.fa-wheelchair-alt,.fa.fa-wpbeginner,.fa.fa-wpforms{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-wheelchair-alt:before{content:"\f368"}.fa.fa-question-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-question-circle-o:before{content:"\f059"}.fa.fa-volume-control-phone:before{content:"\f2a0"}.fa.fa-asl-interpreting:before{content:"\f2a3"}.fa.fa-deafness:before,.fa.fa-hard-of-hearing:before{content:"\f2a4"}.fa.fa-glide,.fa.fa-glide-g{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-signing:before{content:"\f2a7"}.fa.fa-viadeo,.fa.fa-viadeo-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-viadeo-square:before{content:"\f2aa"}.fa.fa-snapchat,.fa.fa-snapchat-ghost{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-snapchat-ghost:before{content:"\f2ab"}.fa.fa-snapchat-square{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-snapchat-square:before{content:"\f2ad"}.fa.fa-first-order,.fa.fa-google-plus-official,.fa.fa-pied-piper,.fa.fa-themeisle,.fa.fa-yoast{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus-official:before{content:"\f2b3"}.fa.fa-google-plus-circle{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-google-plus-circle:before{content:"\f2b3"}.fa.fa-fa,.fa.fa-font-awesome{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-fa:before{content:"\f2b4"}.fa.fa-handshake-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-handshake-o:before{content:"\f2b5"}.fa.fa-envelope-open-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-envelope-open-o:before{content:"\f2b6"}.fa.fa-linode{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-address-book-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-address-book-o:before{content:"\f2b9"}.fa.fa-vcard:before{content:"\f2bb"}.fa.fa-address-card-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-address-card-o:before{content:"\f2bb"}.fa.fa-vcard-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-vcard-o:before{content:"\f2bb"}.fa.fa-user-circle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-user-circle-o:before{content:"\f2bd"}.fa.fa-user-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-user-o:before{content:"\f007"}.fa.fa-id-badge{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-drivers-license:before{content:"\f2c2"}.fa.fa-id-card-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-id-card-o:before{content:"\f2c2"}.fa.fa-drivers-license-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-drivers-license-o:before{content:"\f2c2"}.fa.fa-free-code-camp,.fa.fa-quora,.fa.fa-telegram{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-thermometer-4:before,.fa.fa-thermometer:before{content:"\f2c7"}.fa.fa-thermometer-3:before{content:"\f2c8"}.fa.fa-thermometer-2:before{content:"\f2c9"}.fa.fa-thermometer-1:before{content:"\f2ca"}.fa.fa-thermometer-0:before{content:"\f2cb"}.fa.fa-bathtub:before,.fa.fa-s15:before{content:"\f2cd"}.fa.fa-window-maximize,.fa.fa-window-restore{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-times-rectangle:before{content:"\f410"}.fa.fa-window-close-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-window-close-o:before{content:"\f410"}.fa.fa-times-rectangle-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-times-rectangle-o:before{content:"\f410"}.fa.fa-bandcamp,.fa.fa-eercast,.fa.fa-etsy,.fa.fa-grav,.fa.fa-imdb,.fa.fa-ravelry{font-family:"Font Awesome 6 Brands";font-weight:400}.fa.fa-eercast:before{content:"\f2da"}.fa.fa-snowflake-o{font-family:"Font Awesome 6 Free";font-weight:400}.fa.fa-snowflake-o:before{content:"\f2dc"}.fa.fa-meetup,.fa.fa-superpowers,.fa.fa-wpexplorer{font-family:"Font Awesome 6 Brands";font-weight:400} \ No newline at end of file diff --git a/static/fontawesome/webfonts/fa-brands-400.ttf b/static/fontawesome/webfonts/fa-brands-400.ttf new file mode 100644 index 0000000..5efb1d4 Binary files /dev/null and b/static/fontawesome/webfonts/fa-brands-400.ttf differ diff --git a/static/fontawesome/webfonts/fa-brands-400.woff2 b/static/fontawesome/webfonts/fa-brands-400.woff2 new file mode 100644 index 0000000..36fbda7 Binary files /dev/null and b/static/fontawesome/webfonts/fa-brands-400.woff2 differ diff --git a/static/fontawesome/webfonts/fa-regular-400.ttf b/static/fontawesome/webfonts/fa-regular-400.ttf new file mode 100644 index 0000000..838b4e2 Binary files /dev/null and b/static/fontawesome/webfonts/fa-regular-400.ttf differ diff --git a/static/fontawesome/webfonts/fa-regular-400.woff2 b/static/fontawesome/webfonts/fa-regular-400.woff2 new file mode 100644 index 0000000..b6cabba Binary files /dev/null and b/static/fontawesome/webfonts/fa-regular-400.woff2 differ diff --git a/static/fontawesome/webfonts/fa-solid-900.ttf b/static/fontawesome/webfonts/fa-solid-900.ttf new file mode 100644 index 0000000..ec24749 Binary files /dev/null and b/static/fontawesome/webfonts/fa-solid-900.ttf differ diff --git a/static/fontawesome/webfonts/fa-solid-900.woff2 b/static/fontawesome/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..824d518 Binary files /dev/null and b/static/fontawesome/webfonts/fa-solid-900.woff2 differ diff --git a/static/fontawesome/webfonts/fa-v4compatibility.ttf b/static/fontawesome/webfonts/fa-v4compatibility.ttf new file mode 100644 index 0000000..b175aa8 Binary files /dev/null and b/static/fontawesome/webfonts/fa-v4compatibility.ttf differ diff --git a/static/fontawesome/webfonts/fa-v4compatibility.woff2 b/static/fontawesome/webfonts/fa-v4compatibility.woff2 new file mode 100644 index 0000000..e09b5a5 Binary files /dev/null and b/static/fontawesome/webfonts/fa-v4compatibility.woff2 differ diff --git a/static/img/filter.svg b/static/img/filter.svg new file mode 100644 index 0000000..f8b6af6 --- /dev/null +++ b/static/img/filter.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/static/img/logo.jpg b/static/img/logo.jpg new file mode 100644 index 0000000..edac717 Binary files /dev/null and b/static/img/logo.jpg differ diff --git a/static/img/search.svg b/static/img/search.svg new file mode 100644 index 0000000..648171e --- /dev/null +++ b/static/img/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/labels/_blank.svg b/static/labels/_blank.svg new file mode 100644 index 0000000..9d600b1 --- /dev/null +++ b/static/labels/_blank.svg @@ -0,0 +1,17 @@ + + Offer ribbon (top-right) + + + + + + + + NEW + + + diff --git a/static/labels/new.svg b/static/labels/new.svg new file mode 100644 index 0000000..ffa7f27 --- /dev/null +++ b/static/labels/new.svg @@ -0,0 +1,17 @@ + + Offer ribbon (top-right) + + + + + + + + NEW + + + diff --git a/static/labels/offer.svg b/static/labels/offer.svg new file mode 100644 index 0000000..d4752e5 --- /dev/null +++ b/static/labels/offer.svg @@ -0,0 +1,19 @@ + + Offer ribbon + + + + + + + + + + OFFER + + + diff --git a/static/nav-labels/new.svg b/static/nav-labels/new.svg new file mode 100644 index 0000000..04c43c3 --- /dev/null +++ b/static/nav-labels/new.svg @@ -0,0 +1,14 @@ + + New + + + + + + NEW + + diff --git a/static/nav-labels/offer.svg b/static/nav-labels/offer.svg new file mode 100644 index 0000000..41b2a37 --- /dev/null +++ b/static/nav-labels/offer.svg @@ -0,0 +1,16 @@ + + + + Offer + + + + + + OFFER + + diff --git a/static/order/a-z.svg b/static/order/a-z.svg new file mode 100644 index 0000000..c25cfb9 --- /dev/null +++ b/static/order/a-z.svg @@ -0,0 +1,10 @@ + + + + + + A–Z + diff --git a/static/order/h-l.svg b/static/order/h-l.svg new file mode 100644 index 0000000..c487d57 --- /dev/null +++ b/static/order/h-l.svg @@ -0,0 +1,10 @@ + + + + + + £ ↓ + diff --git a/static/order/l-h.svg b/static/order/l-h.svg new file mode 100644 index 0000000..2bcf700 --- /dev/null +++ b/static/order/l-h.svg @@ -0,0 +1,10 @@ + + + + + + £ ↑ + diff --git a/static/order/z-a.svg b/static/order/z-a.svg new file mode 100644 index 0000000..f544a32 --- /dev/null +++ b/static/order/z-a.svg @@ -0,0 +1,10 @@ + + + + + + Z-A + diff --git a/static/scripts/body.js b/static/scripts/body.js new file mode 100644 index 0000000..5d88a72 --- /dev/null +++ b/static/scripts/body.js @@ -0,0 +1,842 @@ +// ============================================================================ +// 1. Mobile navigation toggle +// - Handles opening/closing the mobile nav panel +// - Updates ARIA attributes for accessibility +// - Closes panel when a link inside it is clicked +// ============================================================================ + +(function () { + const btn = document.getElementById('nav-toggle'); + const panel = document.getElementById('mobile-nav'); + if (!btn || !panel) return; // No mobile nav in this layout, abort + + btn.addEventListener('click', () => { + // Toggle the "hidden" class on the panel. + // classList.toggle returns true if the class is present AFTER the call. + const isHidden = panel.classList.toggle('hidden'); + const expanded = !isHidden; // aria-expanded = true when the panel is visible + + btn.setAttribute('aria-expanded', String(expanded)); + btn.setAttribute('aria-label', expanded ? 'Close menu' : 'Open menu'); + }); + + // Close panel when clicking any link inside the mobile nav + panel.addEventListener('click', (e) => { + const a = e.target.closest('a'); + if (!a) return; + + panel.classList.add('hidden'); + btn.setAttribute('aria-expanded', 'false'); + btn.setAttribute('aria-label', 'Open menu'); + }); +})(); + + +// ============================================================================ +// 2. Image gallery +// - Supports multiple galleries via [data-gallery-root] +// - Thumbnail navigation, prev/next arrows, keyboard arrows, touch swipe +// - HTMX-aware: runs on initial load and after HTMX swaps +// ============================================================================ + +(() => { + /** + * Initialize any galleries found within a given DOM subtree. + * @param {ParentNode} root - Root element to search in (defaults to document). + */ + function initGallery(root) { + if (!root) return; + + // Find all nested gallery roots + const galleries = root.querySelectorAll('[data-gallery-root]'); + + // If root itself is a gallery and no nested galleries exist, + // initialize just the root. + if (!galleries.length && root.matches?.('[data-gallery-root]')) { + initOneGallery(root); + return; + } + + galleries.forEach(initOneGallery); + } + + /** + * Initialize a single gallery instance. + * This attaches handlers only once, even if HTMX re-inserts the fragment. + * @param {Element} root - Element with [data-gallery-root]. + */ + function initOneGallery(root) { + // Prevent double-initialization (HTMX may re-insert the same fragment) + if (root.dataset.galleryInitialized === 'true') return; + root.dataset.galleryInitialized = 'true'; + + let index = 0; + + // Collect all image URLs from [data-image-src] attributes + const imgs = Array.from(root.querySelectorAll('[data-image-src]')) + .map(el => el.getAttribute('data-image-src') || el.dataset.imageSrc) + .filter(Boolean); + + const main = root.querySelector('[data-main-img]'); + const prevBtn = root.querySelector('[data-prev]'); + const nextBtn = root.querySelector('[data-next]'); + const thumbs = Array.from(root.querySelectorAll('[data-thumb]')); + const titleEl = root.querySelector('[data-title]'); + const total = imgs.length; + + // Without a main image or any sources, the gallery is not usable + if (!main || !total) return; + + /** + * Render the gallery to reflect the current `index`: + * - Update main image src/alt + * - Update active thumbnail highlight + * - Keep prev/next button ARIA labels consistent + */ + function render() { + main.setAttribute('src', imgs[index]); + + // Highlight active thumbnail + thumbs.forEach((t, i) => { + if (i === index) t.classList.add('ring-2', 'ring-stone-900'); + else t.classList.remove('ring-2', 'ring-stone-900'); + }); + + // Basic ARIA labels for navigation buttons + if (prevBtn && nextBtn) { + prevBtn.setAttribute('aria-label', 'Previous image'); + nextBtn.setAttribute('aria-label', 'Next image'); + } + + // Alt text uses base title + position (e.g. "Product image (1/4)") + const baseTitle = (titleEl?.textContent || 'Product image').trim(); + main.setAttribute('alt', `${baseTitle} (${index + 1}/${total})`); + } + + /** + * Move to a specific index, wrapping around at bounds. + * @param {number} n - Desired index (can be out-of-bounds; we mod it). + */ + function go(n) { + index = (n + imgs.length) % imgs.length; + render(); + } + + // --- Button handlers ---------------------------------------------------- + + prevBtn?.addEventListener('click', (e) => { + e.preventDefault(); + go(index - 1); + }); + + nextBtn?.addEventListener('click', (e) => { + e.preventDefault(); + go(index + 1); + }); + + // --- Thumbnail handlers ------------------------------------------------- + + thumbs.forEach((t, i) => { + t.addEventListener('click', (e) => { + e.preventDefault(); + go(i); + }); + }); + + // --- Keyboard navigation (left/right arrows) --------------------------- + // Note: we only act if `root` is still attached to the DOM. + const keyHandler = (e) => { + if (!root.isConnected) return; + if (e.key === 'ArrowLeft') go(index - 1); + if (e.key === 'ArrowRight') go(index + 1); + }; + document.addEventListener('keydown', keyHandler); + + // --- Touch swipe on main image (horizontal only) ----------------------- + + let touchStartX = null; + let touchStartY = null; + const SWIPE_MIN = 30; // px + + main.addEventListener('touchstart', (e) => { + const t = e.changedTouches[0]; + touchStartX = t.clientX; + touchStartY = t.clientY; + }, { passive: true }); + + main.addEventListener('touchend', (e) => { + if (touchStartX === null) return; + + const t = e.changedTouches[0]; + const dx = t.clientX - touchStartX; + const dy = t.clientY - touchStartY; + + // Horizontal swipe: dx large, dy relatively small + if (Math.abs(dx) > SWIPE_MIN && Math.abs(dy) < 0.6 * Math.abs(dx)) { + if (dx < 0) go(index + 1); + else go(index - 1); + } + + touchStartX = touchStartY = null; + }, { passive: true }); + + // Initial UI state + render(); + } + + // Initialize all galleries on initial page load + document.addEventListener('DOMContentLoaded', () => { + initGallery(document); + }); + + // Re-initialize galleries inside new fragments from HTMX + if (window.htmx) { + // htmx.onLoad runs on initial load and after each swap + htmx.onLoad((content) => { + initGallery(content); + }); + + // Alternative: + // htmx.on('htmx:afterSwap', (evt) => { + // initGallery(evt.detail.target); + // }); + } +})(); + + +// ============================================================================ +// 3. "Peek" scroll viewport +// - Adds a clipped/peek effect to scrollable containers +// - Uses negative margins and optional CSS mask fade +// - Automatically updates on resize and DOM mutations +// ============================================================================ + +(() => { + /** + * Safely parse a numeric value or fall back to a default. + */ + function px(val, def) { + const n = Number(val); + return Number.isFinite(n) ? n : def; + } + + /** + * Apply the peek effect to a viewport and its inner content. + * @param {HTMLElement} vp - The viewport (with data-peek-viewport). + * @param {HTMLElement} inner - Inner content wrapper. + */ + function applyPeek(vp, inner) { + const edge = (vp.dataset.peekEdge || 'bottom').toLowerCase(); + const useMask = vp.dataset.peekMask === 'true'; + + // Compute peek size in pixels: + // - data-peek-size-px: direct px value + // - data-peek-size: "units" that are scaled by root font size * 0.25 + // - default: 24px + const sizePx = + px(vp.dataset.peekSizePx, NaN) || + px(vp.dataset.peekSize, NaN) * + (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) * + 0.25 || + 24; + + const overflowing = vp.scrollHeight > vp.clientHeight; + + // Reset any previous modifications + inner.style.marginTop = ''; + inner.style.marginBottom = ''; + vp.style.webkitMaskImage = vp.style.maskImage = ''; + + // Reset last child's margin in case we changed it previously + const last = inner.lastElementChild; + if (last) last.style.marginBottom = ''; + + if (!overflowing) return; + + // NOTE: For clipping to look right, we want the viewport's own bottom padding + // to be minimal. Consider also using pb-0 in CSS if needed. + + // Apply negative margins to "cut" off content at top/bottom, creating peek + if (edge === 'bottom' || edge === 'both') inner.style.marginBottom = `-${sizePx}px`; + if (edge === 'top' || edge === 'both') inner.style.marginTop = `-${sizePx}px`; + + // Prevent the very last child from cancelling the visual clip + if (edge === 'bottom' || edge === 'both') { + if (last) last.style.marginBottom = '0px'; + } + + // Optional fade in/out mask on top/bottom + if (useMask) { + const topStop = (edge === 'top' || edge === 'both') ? `${sizePx}px` : '0px'; + const bottomStop = (edge === 'bottom' || edge === 'both') ? `${sizePx}px` : '0px'; + const mask = `linear-gradient( + 180deg, + transparent 0, + black ${topStop}, + black calc(100% - ${bottomStop}), + transparent 100% + )`; + vp.style.webkitMaskImage = vp.style.maskImage = mask; + } + } + + /** + * Set up one viewport with peek behavior. + * @param {HTMLElement} vp - Element with [data-peek-viewport]. + */ + function setupViewport(vp) { + const inner = vp.querySelector('[data-peek-inner]') || vp.firstElementChild; + if (!inner) return; + + const update = () => applyPeek(vp, inner); + + // Observe size changes (viewport & inner) + const ro = 'ResizeObserver' in window ? new ResizeObserver(update) : null; + ro?.observe(vp); + ro?.observe(inner); + + // Observe DOM changes inside the inner container + const mo = new MutationObserver(update); + mo.observe(inner, { childList: true, subtree: true }); + + // Run once on window load and once immediately + window.addEventListener('load', update, { once: true }); + update(); + } + + /** + * Initialize peek behavior for all [data-peek-viewport] elements + * inside the given root. + */ + function initPeek(root = document) { + root.querySelectorAll('[data-peek-viewport]').forEach(setupViewport); + } + + // Run on initial DOM readiness + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initPeek()); + } else { + initPeek(); + } + + // Expose for dynamic inserts (e.g., from HTMX or other JS) + window.initPeekScroll = initPeek; +})(); + + +// ============================================================================ +// 4. Exclusive
behavior +// - Only one
with the same [data-toggle-group] is open at a time +// - Respects HTMX swaps by re-attaching afterSwap +// - Scrolls to top when opening a panel +// ============================================================================ + +/** + * Attach behavior so that only one
in each data-toggle-group is open. + * @param {ParentNode} root - Limit binding to within this node (defaults to document). + */ +function attachExclusiveDetailsBehavior(root = document) { + const detailsList = root.querySelectorAll('details[data-toggle-group]'); + + detailsList.forEach((el) => { + // Prevent double-binding on the same element + if (el.__exclusiveBound) return; + el.__exclusiveBound = true; + + el.addEventListener('toggle', function () { + // Only act when this
was just opened + if (!el.open) return; + + const group = el.getAttribute('data-toggle-group'); + if (!group) return; + + // Close all other
with the same data-toggle-group + document + .querySelectorAll('details[data-toggle-group="' + group + '"]') + .forEach((other) => { + if (other === el) return; + if (other.open) { + other.open = false; + } + }); + + // Scroll to top when a panel is opened + window.scrollTo(0, 0); + }); + }); +} + +// Initial binding on page load +attachExclusiveDetailsBehavior(); + +// Re-bind for new content after HTMX swaps +document.body.addEventListener('htmx:afterSwap', function (evt) { + attachExclusiveDetailsBehavior(evt.target); +}); + + +// ============================================================================ +// 5. Close
panels before HTMX requests +// - When a link/button inside a triggers HTMX, +// we close that panel and scroll to top. +// ============================================================================ + +document.body.addEventListener('htmx:beforeRequest', function (evt) { + const triggerEl = evt.target; + + // Find the closest
panel (e.g., mobile panel, filters, etc.) + const panel = triggerEl.closest('details[data-toggle-group]'); + if (!panel) return; + + panel.open = false; + window.scrollTo(0, 0); +}); + + +// ============================================================================ +// 6. Ghost / Koenig video card fix +// - Ghost/Koenig editors may output
+// - This replaces the
with just the