Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow declaring that a proposal must happen before another proposal #1357

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 106 additions & 8 deletions apps/cfp_review/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import timedelta
from collections import defaultdict, Counter
from itertools import combinations
from werkzeug.exceptions import BadRequest

import dateutil
from flask import (
Expand Down Expand Up @@ -56,6 +57,8 @@
AddNoteForm,
ChangeProposalOwner,
ReversionForm,
AddOrderingForm,
RemoveOrderingForm,
)
from . import (
cfp_review,
Expand All @@ -65,6 +68,7 @@
get_next_proposal_to,
copy_request_args,
)
from ..common import get_next_url
from ..common.email import from_email


Expand Down Expand Up @@ -101,12 +105,14 @@
raise ValueError("Invalid querystring boolean")


def filter_proposal_request():
def filter_proposal_request(base_query=None):
if base_query is None:
base_query = Proposal.query
bool_names = ["one_day", "needs_help", "needs_money"]
bool_vals = [request.args.get(n, type=bool_qs) for n in bool_names]
bool_dict = {n: v for n, v in zip(bool_names, bool_vals) if v is not None}

proposal_query = Proposal.query.filter_by(**bool_dict)
proposal_query = base_query.filter_by(**bool_dict)

filtered = False

Expand All @@ -122,7 +128,6 @@

show_user_scheduled = request.args.get("show_user_scheduled", type=bool_qs)
if show_user_scheduled is None or show_user_scheduled is False:
filtered = False
proposal_query = proposal_query.filter_by(user_scheduled=False)

# This block has to be last because it will join to the user table
Expand Down Expand Up @@ -869,7 +874,7 @@
if form.validate_on_submit():
if form.confirm.data:
min_votes = session["min_votes"]
for (prop, vote_count) in proposals:
for prop, vote_count in proposals:
if vote_count >= min_votes and prop.state != "reviewed":
prop.set_state("reviewed")

Expand All @@ -896,7 +901,7 @@
# Find proposals where the submitter has already had an accepted proposal
# or another proposal in this list
duplicates = {}
for (prop, _) in proposals:
for prop, _ in proposals:
if prop.user.proposals.count() > 1:
# Only add each proposal once
if prop.user not in duplicates:
Expand Down Expand Up @@ -937,8 +942,7 @@
if form.confirm.data:
min_score = session["min_score"]
count = 0
for (prop, score) in scored_proposals:

for prop, score in scored_proposals:
if score >= min_score:
count += 1
prop.set_state("accepted")
Expand Down Expand Up @@ -1168,7 +1172,7 @@

clashes = []
offset = 0
for ((id1, id2), count) in popularity.most_common()[:1000]:
for (id1, id2), count in popularity.most_common()[:1000]:
offset += 1
prop1 = Proposal.query.get(id1)
prop2 = Proposal.query.get(id2)
Expand Down Expand Up @@ -1232,4 +1236,98 @@
)


@cfp_review.route("/proposals/add-happens-relationship", methods=["GET", "POST"])
@admin_required
def add_happens_relationship():
form = AddOrderingForm()
if form.validate_on_submit():
happens_first = Proposal.query.get_or_404(form.happens_first_id.data)
happens_later = Proposal.query.get_or_404(form.happens_later_id.data)
happens_first.happens_before.append(happens_later)
if violations := happens_first.find_happens_before_causality_violations():
flash(f"Found a causality violation: {violations}")
else:
db.session.add(happens_first)
db.session.commit()
flash(
f"Added happens-before relationship between {happens_first.title} and {happens_later.title}.",
)
return redirect(get_next_url(default=url_for(".proposals")))
Dismissed Show dismissed Hide dismissed

if "happens_first_id" in request.args:
proposal = Proposal.query.get_or_404(request.args["happens_first_id"])
direction = "happen after"

def generate_form(candidate):
form = AddOrderingForm()
form.happens_first_id.data = proposal.id
form.happens_later_id.data = candidate.id
return form

elif "happens_later_id" in request.args:
proposal = Proposal.query.get_or_404(request.args["happens_later_id"])
direction = "happen before"

def generate_form(candidate):
form = AddOrderingForm()
form.happens_first_id.data = candidate.id
form.happens_later_id.data = proposal.id
return form

else:
raise BadRequest("No proposal ID specified")

base_qs = {
k: v
for k, v in request.args.items()
if k in ["happens_first_id", "happens_later_id", "next"]
}

base_query = Proposal.query.filter(Proposal.id != proposal.id)
proposals, filtered = filter_proposal_request(base_query=base_query)
non_sort_query_string = copy_request_args(request.args)

if "sort_by" in non_sort_query_string:
del non_sort_query_string["sort_by"]

if "reverse" in non_sort_query_string:
del non_sort_query_string["reverse"]

tag_counts = {t.tag: [0, len(t.proposals)] for t in Tag.query.all()}
for prop in proposals:
for t in prop.tags:
tag_counts[t.tag][0] = tag_counts[t.tag][0] + 1

return render_template(
"cfp_review/add_happens_relationship.html",
proposal=proposal,
generate_form=generate_form,
proposals=proposals,
base_qs=base_qs,
new_qs=non_sort_query_string,
filtered=filtered,
total_proposals=base_query.count(),
tag_counts=tag_counts,
direction=direction,
)


@cfp_review.route("/proposals/remove-happens-relationship", methods=["POST"])
@admin_required
def remove_happens_relationship():
form = RemoveOrderingForm()

if form.validate_on_submit():
happens_first = Proposal.query.get_or_404(form.happens_first_id.data)
happens_later = Proposal.query.get_or_404(form.happens_later_id.data)
happens_first.happens_before.remove(happens_later)
db.session.add(happens_first)
db.session.commit()
flash(
f"Removed happens-before relationship between {happens_first.title} and {happens_later.title}."
)

return redirect(form.next.data)


from . import venues # noqa
12 changes: 12 additions & 0 deletions apps/cfp_review/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,15 @@ class ReversionForm(Form):
proposal_id = HiddenIntegerField("Proposal ID")
txn_id = HiddenIntegerField("Transaction ID")
revert = SubmitField("Revert to this version")


class RemoveOrderingForm(Form):
happens_first_id = HiddenIntegerField("Happens First")
happens_later_id = HiddenIntegerField("Happens Later")
next = StringField("Return to URL")


class AddOrderingForm(Form):
happens_first_id = HiddenIntegerField("Happens First")
happens_later_id = HiddenIntegerField("Happens Later")
submit = SubmitField("Select")
20 changes: 19 additions & 1 deletion apps/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import os.path
from textwrap import wrap
import pendulum
from urllib.parse import urlparse, urljoin

from main import db, external_url
from flask import session, abort, current_app as app, render_template
from flask import session, abort, current_app as app, render_template, request, url_for
from markupsafe import Markup
from flask.json import jsonify
from flask_login import login_user, current_user
Expand Down Expand Up @@ -283,3 +284,20 @@ def load_archive_file(year: int, *path, raise_404=True):
if json_path is None:
return None
return json.load(open(json_path, "r"))


def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in ("http", "https") and ref_url.netloc == test_url.netloc


def get_next_url(default=None):
next_url = request.args.get("next")
if next_url:
if is_safe_url(next_url):
return next_url
app.logger.error(f"Dropping unsafe next URL {repr(next_url)}")
if default is None:
default = url_for("users.account")
return default
21 changes: 1 addition & 20 deletions apps/users/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import time
from urllib.parse import urlparse, urljoin

from flask import (
render_template,
Expand All @@ -25,7 +24,7 @@
from models.cfp import Proposal, CFPMessage
from models.basket import Basket

from ..common import set_user_currency, feature_flag
from ..common import set_user_currency, feature_flag, get_next_url
from ..common.email import from_email
from ..common.forms import Form, EmailField

Expand Down Expand Up @@ -57,23 +56,6 @@
}


def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in ("http", "https") and ref_url.netloc == test_url.netloc


def get_next_url(default=None):
next_url = request.args.get("next")
if next_url:
if is_safe_url(next_url):
return next_url
app.logger.error(f"Dropping unsafe next URL {repr(next_url)}")
if default is None:
default = url_for(".account")
return default


class LoginForm(Form):
email = EmailField("Email")

Expand All @@ -98,20 +80,20 @@
login_user(user)
session.permanent = True

return redirect(get_next_url())

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.


@users.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(get_next_url())

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.

if request.args.get("code"):
user = User.get_by_code(app.config["SECRET_KEY"], request.args.get("code"))
if user is not None:
login_user(user)
session.permanent = True
return redirect(get_next_url())

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.
else:
flash(
"Your login link was invalid. Please enter your email address below to receive a new link."
Expand Down Expand Up @@ -152,7 +134,7 @@
session.permanent = False
Basket.clear_from_session()
logout_user()
return redirect(get_next_url(default=url_for("base.main")))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.


class SignupForm(Form):
Expand Down Expand Up @@ -240,7 +222,6 @@

@users.route("/sso/<site>")
def sso(site=None):

volunteer_sites = [app.config["VOLUNTEER_SITE"]]
if "VOLUNTEER_CAMP_SITE" in app.config:
volunteer_sites.append(app.config["VOLUNTEER_CAMP_SITE"])
Expand Down
91 changes: 91 additions & 0 deletions migrations/versions/8dac53c38bde_create_proposalordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Create ProposalOrdering

Revision ID: 8dac53c38bde
Revises: 5e48dc411113
Create Date: 2024-02-04 17:28:55.400686

"""

# revision identifiers, used by Alembic.
revision = "8dac53c38bde"
down_revision = "5e48dc411113"

from alembic import op
import sqlalchemy as sa


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"proposal_ordering_version",
sa.Column(
"happens_first_proposal_id",
sa.Integer(),
autoincrement=False,
nullable=False,
),
sa.Column(
"happens_later_proposal_id",
sa.Integer(),
autoincrement=False,
nullable=False,
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"happens_first_proposal_id",
"happens_later_proposal_id",
"transaction_id",
name=op.f("pk_proposal_ordering_version"),
),
)
op.create_index(
op.f("ix_proposal_ordering_version_operation_type"),
"proposal_ordering_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_proposal_ordering_version_transaction_id"),
"proposal_ordering_version",
["transaction_id"],
unique=False,
)
op.create_table(
"proposal_ordering",
sa.Column("happens_first_proposal_id", sa.Integer(), nullable=False),
sa.Column("happens_later_proposal_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["happens_first_proposal_id"],
["proposal.id"],
name=op.f("fk_proposal_ordering_happens_first_proposal_id_proposal"),
),
sa.ForeignKeyConstraint(
["happens_later_proposal_id"],
["proposal.id"],
name=op.f("fk_proposal_ordering_happens_later_proposal_id_proposal"),
),
sa.PrimaryKeyConstraint(
"happens_first_proposal_id",
"happens_later_proposal_id",
name=op.f("pk_proposal_ordering"),
),
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("proposal_ordering")
op.drop_index(
op.f("ix_proposal_ordering_version_transaction_id"),
table_name="proposal_ordering_version",
)
op.drop_index(
op.f("ix_proposal_ordering_version_operation_type"),
table_name="proposal_ordering_version",
)
op.drop_table("proposal_ordering_version")
# ### end Alembic commands ###
Loading
Loading