diff --git a/migrations/env.py b/migrations/env.py index 4c97092..7fc75a3 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,3 +1,4 @@ +# pylint: disable=no-member,redefined-outer-name,unused-argument import logging from logging.config import fileConfig diff --git a/migrations/versions/1c96ddd37bf6_add_action_detail_column.py b/migrations/versions/1c96ddd37bf6_add_action_detail_column.py index 9aef553..c6feb3c 100644 --- a/migrations/versions/1c96ddd37bf6_add_action_detail_column.py +++ b/migrations/versions/1c96ddd37bf6_add_action_detail_column.py @@ -5,8 +5,10 @@ Create Date: 2026-04-20 10:58:06.359349 """ +# pylint: disable=no-member from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -17,8 +19,18 @@ def upgrade(): - op.add_column('action', sa.Column('detail', sa.JSON(), nullable=True)) + bind = op.get_bind() + inspector = inspect(bind) + columns = {column["name"] for column in inspector.get_columns("action")} + + if "detail" not in columns: + op.add_column("action", sa.Column("detail", sa.JSON(), nullable=True)) def downgrade(): - op.drop_column('action', 'detail') + bind = op.get_bind() + inspector = inspect(bind) + columns = {column["name"] for column in inspector.get_columns("action")} + + if "detail" in columns: + op.drop_column("action", "detail") diff --git a/migrations/versions/7b6a6f0d9d62_add_wishlist_items_table.py b/migrations/versions/7b6a6f0d9d62_add_wishlist_items_table.py new file mode 100644 index 0000000..9104dad --- /dev/null +++ b/migrations/versions/7b6a6f0d9d62_add_wishlist_items_table.py @@ -0,0 +1,53 @@ +"""add wishlist items table + +Revision ID: 7b6a6f0d9d62 +Revises: 1c96ddd37bf6 +Create Date: 2026-05-07 14:00:00.000000 + +""" +# pylint: disable=no-member,duplicate-code +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = "7b6a6f0d9d62" +down_revision = "1c96ddd37bf6" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + + if "wishlist_item" not in inspector.get_table_names(): + op.create_table( + "wishlist_item", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("roadmap_item_id", sa.String(length=255), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("section", sa.String(length=100), nullable=True), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("href", sa.String(length=500), nullable=True), + sa.Column("priority", sa.String(length=20), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "roadmap_item_id", + name="uq_wishlist_user_roadmap_item", + ), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + + if "wishlist_item" in inspector.get_table_names(): + op.drop_table("wishlist_item") diff --git a/migrations/versions/c4e8d0f9a1b2_backfill_wishlist_item_columns.py b/migrations/versions/c4e8d0f9a1b2_backfill_wishlist_item_columns.py new file mode 100644 index 0000000..c11ab60 --- /dev/null +++ b/migrations/versions/c4e8d0f9a1b2_backfill_wishlist_item_columns.py @@ -0,0 +1,97 @@ +"""backfill wishlist item columns + +Revision ID: c4e8d0f9a1b2 +Revises: 7b6a6f0d9d62 +Create Date: 2026-05-07 21:05:00.000000 + +""" +# pylint: disable=no-member,duplicate-code +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = "c4e8d0f9a1b2" +down_revision = "7b6a6f0d9d62" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + + if "wishlist_item" not in inspector.get_table_names(): + return + + columns = {column["name"] for column in inspector.get_columns("wishlist_item")} + + if "roadmap_item_id" not in columns: + op.add_column( + "wishlist_item", + sa.Column("roadmap_item_id", sa.String(length=255), nullable=True), + ) + if "title" not in columns: + op.add_column( + "wishlist_item", + sa.Column("title", sa.String(length=255), nullable=True), + ) + if "section" not in columns: + op.add_column( + "wishlist_item", + sa.Column("section", sa.String(length=100), nullable=True), + ) + if "summary" not in columns: + op.add_column( + "wishlist_item", + sa.Column("summary", sa.Text(), nullable=True), + ) + if "href" not in columns: + op.add_column( + "wishlist_item", + sa.Column("href", sa.String(length=500), nullable=True), + ) + if "priority" not in columns: + op.add_column( + "wishlist_item", + sa.Column( + "priority", + sa.String(length=20), + nullable=False, + server_default="low", + ), + ) + if "created_at" not in columns: + op.add_column( + "wishlist_item", + sa.Column("created_at", sa.DateTime(), nullable=True), + ) + if "updated_at" not in columns: + op.add_column( + "wishlist_item", + sa.Column("updated_at", sa.DateTime(), nullable=True), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + + if "wishlist_item" not in inspector.get_table_names(): + return + + columns = {column["name"] for column in inspector.get_columns("wishlist_item")} + + for name in ( + "updated_at", + "created_at", + "priority", + "href", + "summary", + "section", + "title", + "roadmap_item_id", + ): + if name in columns: + op.drop_column("wishlist_item", name) diff --git a/tests/conftest.py b/tests/conftest.py index 272c59a..49a4671 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,33 +36,29 @@ def app_ctx(app): @pytest.fixture(autouse=True) -def cleanup_db(app_ctx): +def cleanup_db(_app_ctx): """Automatically clean up database before and after each test.""" - from website.models import Note, CameraGear, LabEquipment, Consumable, User - + table_names = set(db.metadata.tables.keys()) + # Clean up before test db.session.rollback() try: - # Delete in reverse order of dependencies - Note.query.delete() - CameraGear.query.delete() - LabEquipment.query.delete() - Consumable.query.delete() - User.query.delete() + # Delete in reverse dependency order for all known tables. + for table in reversed(db.metadata.sorted_tables): + if table.name in table_names: + db.session.execute(table.delete()) db.session.commit() except Exception: # pragma: no cover db.session.rollback() - + yield - + # Clean up after test db.session.rollback() try: - Note.query.delete() - CameraGear.query.delete() - LabEquipment.query.delete() - Consumable.query.delete() - User.query.delete() + for table in reversed(db.metadata.sorted_tables): + if table.name in table_names: + db.session.execute(table.delete()) db.session.commit() except Exception: # pragma: no cover db.session.rollback() diff --git a/website/__init__.py b/website/__init__.py index 21445ac..cce8c30 100644 --- a/website/__init__.py +++ b/website/__init__.py @@ -26,14 +26,6 @@ db = SQLAlchemy() -from .views import ( - dashboard_blueprint, - landing_blueprint, - roadmap_blueprint, - auth_blueprint, -) - - # Project root: parent of the `website` package _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) load_dotenv(os.path.join(_PROJECT_ROOT, ".env")) @@ -95,7 +87,7 @@ def load_user(user_id): return User.query.get(int(user_id)) db.init_app(app) - migrate = Migrate(app, db) + Migrate(app, db) with app.app_context(): import website.models.tracking # pylint: disable=unused-import from .views import ( diff --git a/website/models/user.py b/website/models/user.py index aef1ac7..7c0c924 100644 --- a/website/models/user.py +++ b/website/models/user.py @@ -28,6 +28,12 @@ class User(db.Model, UserMixin): career_goal = db.Column(db.String(50)) career_stage = db.Column(db.String(50)) priority = db.Column(db.String(50)) + wishlist_items = db.relationship( + "WishlistItem", + backref="user", + lazy=True, + cascade="all, delete-orphan", + ) def __repr__(self): """Return a concise representation for debugging.""" @@ -47,8 +53,42 @@ def save(self): """Save the user to the database""" db.session.add(self) db.session.commit() - +class WishlistItem(db.Model): + """Wishlist item saved from roadmap interactions for an authenticated user.""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + roadmap_item_id = db.Column(db.String(255), nullable=False) + label = db.Column(db.String(255), nullable=True) + title = db.Column(db.String(255), nullable=False) + section = db.Column(db.String(100), nullable=True) + summary = db.Column(db.Text, nullable=True) + href = db.Column(db.String(500), nullable=True) + priority = db.Column(db.String(20), nullable=False, default="low") + created_at = db.Column(db.DateTime, default=db.func.current_timestamp()) + updated_at = db.Column( + db.DateTime, + default=db.func.current_timestamp(), + onupdate=db.func.current_timestamp(), + ) + + __table_args__ = ( + db.UniqueConstraint( + "user_id", "roadmap_item_id", name="uq_wishlist_user_roadmap_item" + ), + ) + def to_dict(self): + """Return a JSON-serializable representation of the wishlist item.""" + return { + "id": self.id, + "roadmap_item_id": self.roadmap_item_id, + "label": self.label or self.title, + "title": self.title, + "section": self.section, + "summary": self.summary, + "href": self.href, + "priority": self.priority, + } diff --git a/website/static/css/roadmap-specific.css b/website/static/css/roadmap-specific.css index ec46c84..5b94a53 100644 --- a/website/static/css/roadmap-specific.css +++ b/website/static/css/roadmap-specific.css @@ -757,57 +757,45 @@ body { } .rm-priority-col { - background-color: #ffffff; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); + background: #ffffff; + border: 1.5px solid var(--border); + border-radius: 12px; padding: 24px; - box-shadow: var(--card-shadow); display: flex; flex-direction: column; + min-height: 280px; } .rm-col-header { font-family: 'Poppins', sans-serif; font-weight: 700; font-size: 1rem; - color: #2d3748; + color: var(--text); margin-bottom: 20px; padding-bottom: 12px; - border-bottom: 2px solid #f1f5f9; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; } -.rm-feature-card { - background: #ffffff; - border: 1px solid #edf2f7; - border-radius: 12px; - overflow: hidden; - margin-bottom: 20px; +.rm-priority-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; } -.rm-feature-img { - width: 100%; - height: 150px; - object-fit: cover; +.rm-priority-dot--high { + background: #ef4444; } -.rm-feature-body { - padding: 16px; - display: flex; - gap: 12px; +.rm-priority-dot--medium { + background: #f59e0b; } -.rm-card-icon { - flex-shrink: 0; - background-color: var(--primary-blue); - color: #fff; - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 0.85rem; +.rm-priority-dot--low { + background: #22c55e; } .rm-checklist { @@ -839,6 +827,101 @@ body { background-color: #fff; } +.rm-three-column-grid--wishlist { + align-items: stretch; +} + +.rm-checklist--wishlist { + display: flex; + flex-direction: column; + gap: 14px; +} + +.rm-checklist--wishlist li { + padding: 0; + border-bottom: 0; +} + +.rm-checklist--wishlist li::before { + display: none; +} + +.rm-wishlist-item { + display: block; + padding: 14px 16px; + border: 1.5px solid rgba(18, 123, 228, 0.22); + border-bottom: 3px solid rgba(18, 123, 228, 0.34); + border-radius: 4px; + background: #fff; + cursor: grab; + transition: + border-color 0.18s ease, + border-bottom-color 0.18s ease, + background-color 0.18s ease; +} + +.rm-wishlist-item:hover, +.rm-wishlist-item:focus-within { + border-color: rgba(18, 123, 228, 0.34); + border-bottom-color: rgba(18, 123, 228, 0.5); + background-color: #f8fbff; +} + +.rm-wishlist-item-title { + color: var(--text); + font-size: 0.96rem; + font-weight: 800; + line-height: 1.45; + text-decoration: none; + display: inline-block; +} + +.rm-wishlist-item-title-link { + color: var(--accent); +} + +.rm-wishlist-item-title-link:hover { + color: var(--accent-dark); + text-decoration: underline; +} + +.rm-wishlist-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + min-height: 420px; + padding: 48px 24px; + border: 1.5px solid var(--border); + border-radius: 20px; + background: #fff; + text-align: center; +} + +.rm-wishlist-empty-art { + width: min(100%, 420px); + max-height: 320px; + height: auto; + object-fit: contain; +} + +.rm-wishlist-empty-title { + margin: 0; + color: var(--text); + font-family: 'Poppins', sans-serif; + font-size: 1.4rem; + font-weight: 700; +} + +.rm-wishlist-empty-copy { + max-width: 460px; + margin: 0; + color: var(--muted); + font-size: 0.96rem; + line-height: 1.7; +} + @media (max-width: 1000px) { .rm-path-wrap { overflow-x: auto; @@ -847,4 +930,3 @@ body { min-width: 900px; } } - diff --git a/website/static/img/wishlist-empty-state.svg b/website/static/img/wishlist-empty-state.svg new file mode 100644 index 0000000..08f160b --- /dev/null +++ b/website/static/img/wishlist-empty-state.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/static/js/roadmap-tracking.js b/website/static/js/roadmap-tracking.js index c23394a..477f8c6 100644 --- a/website/static/js/roadmap-tracking.js +++ b/website/static/js/roadmap-tracking.js @@ -19,6 +19,27 @@ }).catch(function () {}); } + function saveWishlistItem(detail, node) { + if (!detail || !node) return; + + var titleNode = node.querySelector(".item-title-link, .item-title"); + var summaryNode = node.querySelector(".item-meta"); + var linkNode = node.querySelector(".item-title-link"); + + fetch("/wishlist/items/from-roadmap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roadmap_item_id: detail.item_id || "", + title: titleNode ? (titleNode.textContent || "").trim() : detail.label || "", + section: detail.section || "", + summary: summaryNode ? (summaryNode.textContent || "").trim() : "", + href: linkNode ? linkNode.href || "" : "", + checked: !!detail.checked, + }), + }).catch(function () {}); + } + function reportRoadmapTime() { if (hasReportedRoadmapTime) return; @@ -56,12 +77,15 @@ var li = t.closest(".roadmap-node"); if (!li || !grid.contains(li)) return; - postTrack("roadmap_checkbox", { + var checkboxDetail = { section: li.getAttribute("data-rm-section") || "", label: li.getAttribute("data-rm-label") || "", item_id: li.getAttribute("data-rm-id") || "", checked: !!t.checked, - }); + }; + + postTrack("roadmap_checkbox", checkboxDetail); + saveWishlistItem(checkboxDetail, li); } if (t.classList.contains("rm-status-select")) { @@ -105,4 +129,4 @@ }); window.addEventListener("pagehide", reportRoadmapTime); -})(); \ No newline at end of file +})(); diff --git a/website/static/js/wishlist.js b/website/static/js/wishlist.js index f179c61..f2edc16 100644 --- a/website/static/js/wishlist.js +++ b/website/static/js/wishlist.js @@ -1,21 +1,31 @@ function allowDrop(ev) { - ev.preventDefault(); + ev.preventDefault(); } function drag(ev) { - ev.dataTransfer.setData("text", ev.target.id); + ev.dataTransfer.setData("text", ev.currentTarget.id); } function drop(ev) { - ev.preventDefault(); - const data = ev.dataTransfer.getData("text"); - const draggedElement = document.getElementById(data); - - - const column = ev.target.closest(".rm-priority-col"); - - if (column) { - const targetList = column.querySelector("ul"); - targetList.appendChild(draggedElement); - } -} \ No newline at end of file + ev.preventDefault(); + + const data = ev.dataTransfer.getData("text"); + const draggedElement = document.getElementById(data); + const column = ev.target.closest(".rm-priority-col"); + + if (!draggedElement || !column) return; + + const targetList = column.querySelector("ul"); + const itemId = draggedElement.getAttribute("data-item-id"); + const priority = column.getAttribute("data-priority"); + + if (!targetList || !itemId || !priority) return; + + targetList.appendChild(draggedElement); + + fetch("/wishlist/items/" + itemId + "/priority", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: priority }), + }).catch(function () {}); +} diff --git a/website/templates/majorSpecific/cs.html b/website/templates/majorSpecific/cs.html index caa7ca9..bb0eb60 100644 --- a/website/templates/majorSpecific/cs.html +++ b/website/templates/majorSpecific/cs.html @@ -31,6 +31,7 @@
diff --git a/website/templates/majorSpecific/econ.html b/website/templates/majorSpecific/econ.html index e490af0..09109f3 100644 --- a/website/templates/majorSpecific/econ.html +++ b/website/templates/majorSpecific/econ.html @@ -31,6 +31,7 @@ @@ -214,4 +215,4 @@