diff --git a/KerbalStuff/blueprints/anonymous.py b/KerbalStuff/blueprints/anonymous.py index ccba6435..3a320990 100644 --- a/KerbalStuff/blueprints/anonymous.py +++ b/KerbalStuff/blueprints/anonymous.py @@ -144,10 +144,11 @@ def browse_top() -> str: @anonymous.route("/browse/featured") def browse_featured() -> str: - mods = Featured.query.order_by(Featured.created.desc()) - mods, page, total_pages = paginate_query(mods) - mods = [f.mod for f in mods] - return render_template("browse-list.html", mods=mods, page=page, total_pages=total_pages, + featured = Featured.query.order_by(Featured.priority.desc()) + featured, page, total_pages = paginate_query(featured) + mods = [f.mod for f in featured] + return render_template("browse-list.html", mods=mods, featured=featured, + page=page, total_pages=total_pages, url="/browse/featured", name="Featured Mods", rss="/browse/featured.rss") @@ -250,11 +251,12 @@ def singlegame_browse_top(gameshort: str) -> str: @anonymous.route("//browse/featured") def singlegame_browse_featured(gameshort: str) -> str: ga = get_game_info(short=gameshort) - mods = Featured.query.outerjoin(Mod).filter( - Mod.game_id == ga.id).order_by(Featured.created.desc()) - mods, page, total_pages = paginate_query(mods) - mods = [f.mod for f in mods] - return render_template("browse-list.html", mods=mods, page=page, total_pages=total_pages, ga=ga, + featured = Featured.query.outerjoin(Mod)\ + .filter(Mod.game_id == ga.id)\ + .order_by(Featured.priority.desc()) + featured, page, total_pages = paginate_query(featured) + mods = [f.mod for f in featured] + return render_template("browse-list.html", mods=mods, featured=featured, page=page, total_pages=total_pages, ga=ga, url="/browse/featured", name="Featured Mods", rss="/browse/featured.rss") diff --git a/KerbalStuff/blueprints/api.py b/KerbalStuff/blueprints/api.py index e24d53ba..53ae2daa 100644 --- a/KerbalStuff/blueprints/api.py +++ b/KerbalStuff/blueprints/api.py @@ -406,7 +406,7 @@ def browse_top() -> Iterable[Dict[str, Any]]: @api.route("/api/browse/featured") @json_output def browse_featured() -> Iterable[Dict[str, Any]]: - mods = Featured.query.order_by(Featured.created.desc()) + mods = Featured.query.order_by(Featured.priority.desc()) mods, page, total_pages = paginate_query(mods) return serialize_mod_list((f.mod for f in mods)) diff --git a/KerbalStuff/blueprints/mods.py b/KerbalStuff/blueprints/mods.py index 1610552b..4b8f4836 100644 --- a/KerbalStuff/blueprints/mods.py +++ b/KerbalStuff/blueprints/mods.py @@ -6,6 +6,7 @@ from shutil import rmtree from typing import Any, Dict, Tuple, Optional, Union +from sqlalchemy import func import werkzeug.wrappers import user_agents @@ -477,9 +478,12 @@ def feature(mod_id: int) -> Dict[str, Any]: mod, game = _get_mod_game_info(mod_id) if any(Featured.query.filter(Featured.mod_id == mod_id).all()): abort(409) - featured = Featured() - featured.mod = mod - db.add(featured) + max_prio = db.query(func.max(Featured.priority))\ + .outerjoin(Mod)\ + .filter(Mod.game_id == game.id)\ + .scalar() + db.add(Featured(mod=mod, + priority=max_prio + 1 if max_prio is not None else 0)) return {"success": True} @@ -488,14 +492,63 @@ def feature(mod_id: int) -> Dict[str, Any]: @json_output @with_session def unfeature(mod_id: int) -> Dict[str, Any]: - _get_mod_game_info(mod_id) + _, game = _get_mod_game_info(mod_id) featured = Featured.query.filter(Featured.mod_id == mod_id).first() if not featured: abort(404) + for other in Featured.query.outerjoin(Mod)\ + .filter(Mod.game_id == game.id, + Featured.priority > featured.priority)\ + .all(): + other.priority -= 1 db.delete(featured) return {"success": True} +@mods.route('/mod//feature-down', methods=['POST']) +@adminrequired +@json_output +@with_session +def feature_down(mod_id: int) -> Dict[str, Any]: + _, game = _get_mod_game_info(mod_id) + featured = Featured.query.filter(Featured.mod_id == mod_id).first() + if not featured: + abort(404) + if featured.priority > 0: + other = Featured.query.outerjoin(Mod)\ + .filter(Mod.game_id == game.id, + Featured.priority == featured.priority - 1)\ + .first() + featured.priority -= 1 + if other: + other.priority += 1 + return {"success": True} + + +@mods.route('/mod//feature-up', methods=['POST']) +@adminrequired +@json_output +@with_session +def feature_up(mod_id: int) -> Dict[str, Any]: + _, game = _get_mod_game_info(mod_id) + featured = Featured.query.filter(Featured.mod_id == mod_id).first() + if not featured: + abort(404) + max_prio = db.query(func.max(Featured.priority))\ + .outerjoin(Mod)\ + .filter(Mod.game_id == game.id)\ + .scalar() + if featured.priority < max_prio: + other = Featured.query.outerjoin(Mod)\ + .filter(Mod.game_id == game.id, + Featured.priority == featured.priority + 1)\ + .first() + featured.priority += 1 + if other: + other.priority -= 1 + return {"success": True} + + @mods.route('/mod///publish') @with_session @loginrequired diff --git a/KerbalStuff/common.py b/KerbalStuff/common.py index c1cac67b..794c3caa 100644 --- a/KerbalStuff/common.py +++ b/KerbalStuff/common.py @@ -174,7 +174,7 @@ def get_paginated_mods(ga: Optional[Game] = None, query: str = '', page_size: in def get_featured_mods(game_id: Optional[int], limit: int) -> List[Mod]: - mods = Featured.query.outerjoin(Mod).filter(Mod.published).order_by(Featured.created.desc()) + mods = Featured.query.outerjoin(Mod).filter(Mod.published).order_by(Featured.priority.desc()) if game_id: mods = mods.filter(Mod.game_id == game_id) return mods.limit(limit).all() diff --git a/KerbalStuff/objects.py b/KerbalStuff/objects.py index f34665e4..7f3f726e 100644 --- a/KerbalStuff/objects.py +++ b/KerbalStuff/objects.py @@ -27,7 +27,8 @@ class Following(Base): # type: ignore send_autoupdate = Column(Boolean(), default=True, nullable=False) def __init__(self, mod: Optional['Mod'] = None, user: Optional['User'] = None, - send_update: Optional[bool] = True, send_autoupdate: Optional[bool] = True) -> None: + send_update: Optional[bool] = True, + send_autoupdate: Optional[bool] = True) -> None: self.mod = mod self.user = user self.send_update = send_update @@ -40,6 +41,7 @@ class Featured(Base): # type: ignore mod_id = Column(Integer, ForeignKey('mod.id', ondelete='CASCADE')) mod = relationship('Mod', backref=backref('featured', passive_deletes=True, order_by=id)) created = Column(DateTime, default=datetime.now, index=True) + priority = Column(Integer, nullable=False, index=True) def __repr__(self) -> str: return '' % self.id diff --git a/alembic/versions/2024_01_02_14_56_29-f5a5d29ec765.py b/alembic/versions/2024_01_02_14_56_29-f5a5d29ec765.py new file mode 100644 index 00000000..aa16d1b8 --- /dev/null +++ b/alembic/versions/2024_01_02_14_56_29-f5a5d29ec765.py @@ -0,0 +1,63 @@ +"""Add featured.priority + +Revision ID: f5a5d29ec765 +Revises: ba0c9afb6cb0 +Create Date: 2024-01-02 20:57:00.647417 + +""" + +# revision identifiers, used by Alembic. +revision = 'f5a5d29ec765' +down_revision = 'ba0c9afb6cb0' + +from datetime import datetime +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import relationship, backref + +Base = sa.orm.declarative_base() + +class Featured(Base): # type: ignore + __tablename__ = 'featured' + id = sa.Column(sa.Integer, primary_key=True) + mod_id = sa.Column(sa.Integer, sa.ForeignKey('mod.id', ondelete='CASCADE')) + mod = relationship('Mod', backref=backref('featured', passive_deletes=True, order_by=id)) + created = sa.Column(sa.DateTime, default=datetime.now, index=True) + priority = sa.Column(sa.Integer, nullable=False, index=True) + + +class Mod(Base): # type: ignore + __tablename__ = 'mod' + id = sa.Column(sa.Integer, primary_key=True) + game_id = sa.Column(sa.Integer, sa.ForeignKey('game.id', ondelete='CASCADE')) + game = relationship('Game', backref=backref('mods', passive_deletes=True)) + + +class Game(Base): # type: ignore + __tablename__ = 'game' + id = sa.Column(sa.Integer, primary_key=True) + + +def upgrade() -> None: + op.add_column('featured', sa.Column('priority', sa.Integer(), nullable=True)) + op.create_index('ix_featured_priority', 'featured', [sa.text('priority DESC')], unique=False) + + bind = op.get_bind() + session = sa.orm.Session(bind=bind) + prio = 0 + game_id = None + for feature in session.query(Featured)\ + .outerjoin(Mod)\ + .order_by(Mod.game_id, Featured.created)\ + .all(): + prio = prio + 1 if game_id == feature.mod.game_id else 0 + game_id = feature.mod.game_id + feature.priority = prio + session.commit() + + op.alter_column('featured', 'priority', nullable=False) + + +def downgrade() -> None: + op.drop_index('ix_featured_priority', table_name='featured') + op.drop_column('featured', 'priority') diff --git a/frontend/coffee/global.coffee b/frontend/coffee/global.coffee index ba4060f9..24f25141 100644 --- a/frontend/coffee/global.coffee +++ b/frontend/coffee/global.coffee @@ -109,19 +109,56 @@ link.addEventListener('click', (e) -> xhr = new XMLHttpRequest() if e.target.classList.contains('feature-button') xhr.open('POST', "/mod/#{e.target.dataset.mod}/feature") - xhr.setRequestHeader('Accept', 'application/json') - e.target.classList.remove('feature-button') - e.target.classList.add('unfeature-button') - e.target.textContent = 'Unfeature this mod' else xhr.open('POST', "/mod/#{e.target.dataset.mod}/unfeature") - xhr.setRequestHeader('Accept', 'application/json') - e.target.classList.remove('unfeature-button') - e.target.classList.add('feature-button') - e.target.textContent = 'Feature this mod' + xhr.setRequestHeader('Accept', 'application/json') + xhr.onload = () -> + response = JSON.parse this.responseText + if response.success + if e.target.classList.contains('feature-button') + e.target.classList.remove('feature-button') + e.target.classList.add('unfeature-button') + e.target.textContent = 'Unfeature this mod' + else + e.target.classList.remove('unfeature-button') + e.target.classList.add('feature-button') + e.target.textContent = 'Feature this mod' + else + $('#alert-error-text').text response.reason + $('#alert-error').removeClass 'hidden' xhr.send() , false) for link in document.querySelectorAll('.feature-button, .unfeature-button') +link.addEventListener('click', (e) -> + xhr = new XMLHttpRequest() + mod_id = e.target.dataset.mod + xhr.open 'POST', "/mod/#{mod_id}/feature-down" + xhr.setRequestHeader 'Accept', 'application/json' + xhr.onload = () -> + response = JSON.parse this.responseText + if response.success + window.location.reload() + else + $('#alert-error-text').text response.reason + $('#alert-error').removeClass 'hidden' + xhr.send() +, false) for link in document.querySelectorAll('.lower-feature-priority-button') + +link.addEventListener('click', (e) -> + xhr = new XMLHttpRequest() + mod_id = e.target.dataset.mod + xhr.open 'POST', "/mod/#{mod_id}/feature-up" + xhr.setRequestHeader 'Accept', 'application/json' + xhr.onload = () -> + response = JSON.parse this.responseText + if response.success + window.location.reload() + else + $('#alert-error-text').text response.reason + $('#alert-error').removeClass 'hidden' + xhr.send() +, false) for link in document.querySelectorAll('.raise-feature-priority-button') + readCookie = (name) -> nameEQ = name + "=" ca = document.cookie.split(';') diff --git a/frontend/styles/listing.scss b/frontend/styles/listing.scss index cca5c0d0..57fce188 100644 --- a/frontend/styles/listing.scss +++ b/frontend/styles/listing.scss @@ -79,7 +79,9 @@ border-radius: 0; } - .follow-mod-button, .unfollow-mod-button, .download-link, .following-mod-indicator, .locked-mod-indicator { + .follow-mod-button, .unfollow-mod-button, .following-mod-indicator, + .download-link, .locked-mod-indicator, + .lower-feature-priority-button, .raise-feature-priority-button { text-decoration: none; font-size: 20px; margin-top: -2px; diff --git a/generate_revision.sh b/generate_revision.sh index 758657f1..5424f338 100755 --- a/generate_revision.sh +++ b/generate_revision.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash set -e +case "$OSTYPE" in + linux*) + ;; + msys | cygwin) + # Turn off db's volume mounting because we can't make the owners match, + # by mounting it at /var/lib/postgresql/data_dummy instead. + # The db will not persist between restarts, but that's better than + # failing to start and can still be used to investigate some issues. + echo 'Windows OS detected, disabling db volume mounting.' + echo 'NOTE: Database will NOT persist across restarts!' + echo + export DISABLE_DB_VOLUME=_dummy + ;; +esac + docker-compose build backend docker-compose up -d db @@ -16,4 +31,10 @@ chmod g+rw alembic/versions/* && alembic history --verbose """ -sudo chown "${USER}":"${USER}" alembic/versions/* +case "$OSTYPE" in + linux*) + sudo chown "${USER}":"${USER}" alembic/versions/* + ;; + msys | cygwin) + ;; +esac diff --git a/templates/browse-list.html b/templates/browse-list.html index 57b681bb..990fb50e 100644 --- a/templates/browse-list.html +++ b/templates/browse-list.html @@ -47,9 +47,16 @@

{{ name }}

Nothing to see here. If you're looking for a specific mod, why not ask the modder to upload it here?

{% endif %}
- {% for mod in mods %} - {% include "mod-box.html" %} - {% endfor %} + {% if featured %} + {% for feature in featured %} + {% set mod = feature.mod %} + {% include "mod-box.html" %} + {% endfor %} + {% else %} + {% for mod in mods %} + {% include "mod-box.html" %} + {% endfor %} + {% endif %}
{%- if total_pages > 1 -%}
diff --git a/templates/game.html b/templates/game.html index 3146cc63..58e2057c 100644 --- a/templates/game.html +++ b/templates/game.html @@ -48,6 +48,7 @@

Followed Mods Recently updated mods you follow

{% endif %} +{% if featured %}