trusted_user.py 11.8 KB
Newer Older
1
import html
2
3
4
import typing

from http import HTTPStatus
Kevin Morris's avatar
Kevin Morris committed
5

6
from fastapi import APIRouter, Form, HTTPException, Request
7
from fastapi.responses import RedirectResponse, Response
8
from sqlalchemy import and_, func, or_
Kevin Morris's avatar
Kevin Morris committed
9

10
from aurweb import db, l10n, logging, models, time
11
from aurweb.auth import creds, requires_auth
12
from aurweb.exceptions import handle_form_exceptions
13
14
from aurweb.models import User
from aurweb.models.account_type import TRUSTED_USER_AND_DEV_ID, TRUSTED_USER_ID
15
from aurweb.templates import make_context, make_variable_context, render_template
Kevin Morris's avatar
Kevin Morris committed
16
17

router = APIRouter()
18
logger = logging.get_logger(__name__)
Kevin Morris's avatar
Kevin Morris committed
19
20
21
22
23

# Some TU route specific constants.
ITEMS_PER_PAGE = 10  # Paged table size.
MAX_AGENDA_LENGTH = 75  # Agenda table column length.

24
25
26
27
28
29
30
31
32
33
34
ADDVOTE_SPECIFICS = {
    # This dict stores a vote duration and quorum for a proposal.
    # When a proposal is added, duration is added to the current
    # timestamp.
    # "addvote_type": (duration, quorum)
    "add_tu": (7 * 24 * 60 * 60, 0.66),
    "remove_tu": (7 * 24 * 60 * 60, 0.75),
    "remove_inactive_tu": (5 * 24 * 60 * 60, 0.66),
    "bylaws": (7 * 24 * 60 * 60, 0.75)
}

Kevin Morris's avatar
Kevin Morris committed
35
36

@router.get("/tu")
37
@requires_auth
Kevin Morris's avatar
Kevin Morris committed
38
39
40
41
42
async def trusted_user(request: Request,
                       coff: int = 0,  # current offset
                       cby: str = "desc",  # current by
                       poff: int = 0,  # past offset
                       pby: str = "desc"):  # past by
43
44
45
    if not request.user.has_credential(creds.TU_LIST_VOTES):
        return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)

Kevin Morris's avatar
Kevin Morris committed
46
47
48
49
50
51
52
53
    context = make_context(request, "Trusted User")

    current_by, past_by = cby, pby
    current_off, past_off = coff, poff

    context["pp"] = pp = ITEMS_PER_PAGE
    context["prev_len"] = MAX_AGENDA_LENGTH

54
    ts = time.utcnow()
Kevin Morris's avatar
Kevin Morris committed
55
56
57
58
59
60
61
62
63
64
65

    if current_by not in {"asc", "desc"}:
        # If a malicious by was given, default to desc.
        current_by = "desc"
    context["current_by"] = current_by

    if past_by not in {"asc", "desc"}:
        # If a malicious by was given, default to desc.
        past_by = "desc"
    context["past_by"] = past_by

66
67
68
    current_votes = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.End > ts).order_by(
        models.TUVoteInfo.Submitted.desc())
Kevin Morris's avatar
Kevin Morris committed
69
70
71
72
73
74
    context["current_votes_count"] = current_votes.count()
    current_votes = current_votes.limit(pp).offset(current_off)
    context["current_votes"] = reversed(current_votes.all()) \
        if current_by == "asc" else current_votes.all()
    context["current_off"] = current_off

75
76
77
    past_votes = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.End <= ts).order_by(
        models.TUVoteInfo.Submitted.desc())
Kevin Morris's avatar
Kevin Morris committed
78
79
80
81
82
83
    context["past_votes_count"] = past_votes.count()
    past_votes = past_votes.limit(pp).offset(past_off)
    context["past_votes"] = reversed(past_votes.all()) \
        if past_by == "asc" else past_votes.all()
    context["past_off"] = past_off

84
    last_vote = func.max(models.TUVote.VoteID).label("LastVote")
85
86
87
88
    last_votes_by_tu = db.query(models.TUVote).join(models.User).join(
        models.TUVoteInfo,
        models.TUVoteInfo.ID == models.TUVote.VoteID
    ).filter(
89
        and_(models.TUVote.VoteID == models.TUVoteInfo.ID,
90
             models.User.ID == models.TUVote.UserID,
91
             models.TUVoteInfo.End < ts,
92
93
             or_(models.User.AccountTypeID == 2,
                 models.User.AccountTypeID == 4))
94
95
    ).with_entities(
        models.TUVote.UserID,
96
        last_vote,
97
98
99
        models.User.Username
    ).group_by(models.TUVote.UserID).order_by(
        last_vote.desc(), models.User.Username.asc())
Kevin Morris's avatar
Kevin Morris committed
100
101
102
103
104
    context["last_votes_by_tu"] = last_votes_by_tu.all()

    context["current_by_next"] = "asc" if current_by == "desc" else "desc"
    context["past_by_next"] = "asc" if past_by == "desc" else "desc"

105
106
107
108
109
110
    context["q"] = {
        "coff": current_off,
        "cby": current_by,
        "poff": past_off,
        "pby": past_by
    }
Kevin Morris's avatar
Kevin Morris committed
111
112

    return render_template(request, "tu/index.html", context)
113
114


115
def render_proposal(request: Request, context: dict, proposal: int,
116
117
118
                    voteinfo: models.TUVoteInfo,
                    voters: typing.Iterable[models.User],
                    vote: models.TUVote,
119
120
121
122
                    status_code: HTTPStatus = HTTPStatus.OK):
    """ Render a single TU proposal. """
    context["proposal"] = proposal
    context["voteinfo"] = voteinfo
123
    context["voters"] = voters.all()
124

125
    total = voteinfo.total_votes()
126
    participation = (total / voteinfo.ActiveTUs) if voteinfo.ActiveTUs else 0
127
128
129
130
131
132
    context["participation"] = participation

    accepted = (voteinfo.Yes > voteinfo.ActiveTUs / 2) or \
        (participation > voteinfo.Quorum and voteinfo.Yes > voteinfo.No)
    context["accepted"] = accepted

133
    can_vote = voters.filter(models.TUVote.User == request.user).first() is None
134
135
136
137
138
139
140
141
142
143
144
145
146
    context["can_vote"] = can_vote

    if not voteinfo.is_running():
        context["error"] = "Voting is closed for this proposal."

    context["vote"] = vote
    context["has_voted"] = vote is not None

    return render_template(request, "tu/show.html", context,
                           status_code=status_code)


@router.get("/tu/{proposal}")
147
@requires_auth
148
async def trusted_user_proposal(request: Request, proposal: int):
149
150
151
    if not request.user.has_credential(creds.TU_LIST_VOTES):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

152
153
154
    context = await make_variable_context(request, "Trusted User")
    proposal = int(proposal)

155
156
    voteinfo = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.ID == proposal).first()
157
    if not voteinfo:
158
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
159

160
161
162
163
164
    voters = db.query(models.User).join(models.TUVote).filter(
        models.TUVote.VoteID == voteinfo.ID)
    vote = db.query(models.TUVote).filter(
        and_(models.TUVote.UserID == request.user.ID,
             models.TUVote.VoteID == voteinfo.ID)).first()
165
    if not request.user.has_credential(creds.TU_VOTE):
166
        context["error"] = "Only Trusted Users are allowed to vote."
167
    if voteinfo.User == request.user.Username:
168
169
170
171
172
173
174
175
176
        context["error"] = "You cannot vote in an proposal about you."
    elif vote is not None:
        context["error"] = "You've already voted for this proposal."

    context["vote"] = vote
    return render_proposal(request, context, proposal, voteinfo, voters, vote)


@router.post("/tu/{proposal}")
177
@handle_form_exceptions
178
@requires_auth
179
async def trusted_user_proposal_post(request: Request, proposal: int,
180
                                     decision: str = Form(...)):
181
182
183
    if not request.user.has_credential(creds.TU_LIST_VOTES):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

184
185
186
    context = await make_variable_context(request, "Trusted User")
    proposal = int(proposal)  # Make sure it's an int.

187
188
    voteinfo = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.ID == proposal).first()
189
    if not voteinfo:
190
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
191

192
193
194
195
196
    voters = db.query(models.User).join(models.TUVote).filter(
        models.TUVote.VoteID == voteinfo.ID)
    vote = db.query(models.TUVote).filter(
        and_(models.TUVote.UserID == request.user.ID,
             models.TUVote.VoteID == voteinfo.ID)).first()
197
198

    status_code = HTTPStatus.OK
199
    if not request.user.has_credential(creds.TU_VOTE):
200
201
202
203
204
        context["error"] = "Only Trusted Users are allowed to vote."
        status_code = HTTPStatus.UNAUTHORIZED
    elif voteinfo.User == request.user.Username:
        context["error"] = "You cannot vote in an proposal about you."
        status_code = HTTPStatus.BAD_REQUEST
205
    elif vote is not None:
206
207
208
209
210
211
212
213
214
215
216
217
218
        context["error"] = "You've already voted for this proposal."
        status_code = HTTPStatus.BAD_REQUEST

    if status_code != HTTPStatus.OK:
        return render_proposal(request, context, proposal,
                               voteinfo, voters, vote,
                               status_code=status_code)

    if decision in {"Yes", "No", "Abstain"}:
        # Increment whichever decision was given to us.
        setattr(voteinfo, decision, getattr(voteinfo, decision) + 1)
    else:
        return Response("Invalid 'decision' value.",
219
                        status_code=HTTPStatus.BAD_REQUEST)
220

221
    with db.begin():
222
        vote = db.create(models.TUVote, User=request.user, VoteInfo=voteinfo)
223
224
225

    context["error"] = "You've already voted for this proposal."
    return render_proposal(request, context, proposal, voteinfo, voters, vote)
226
227
228


@router.get("/addvote")
229
@requires_auth
230
231
232
233
234
async def trusted_user_addvote(request: Request, user: str = str(),
                               type: str = "add_tu", agenda: str = str()):
    if not request.user.has_credential(creds.TU_ADD_VOTE):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

235
236
237
238
239
240
241
242
243
244
245
246
247
248
    context = await make_variable_context(request, "Add Proposal")

    if type not in ADDVOTE_SPECIFICS:
        context["error"] = "Invalid type."
        type = "add_tu"  # Default it.

    context["user"] = user
    context["type"] = type
    context["agenda"] = agenda

    return render_template(request, "addvote.html", context)


@router.post("/addvote")
249
@handle_form_exceptions
250
@requires_auth
251
252
253
254
async def trusted_user_addvote_post(request: Request,
                                    user: str = Form(default=str()),
                                    type: str = Form(default=str()),
                                    agenda: str = Form(default=str())):
255
256
257
    if not request.user.has_credential(creds.TU_ADD_VOTE):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

258
259
260
261
262
263
264
265
266
267
268
269
270
    # Build a context.
    context = await make_variable_context(request, "Add Proposal")

    context["type"] = type
    context["user"] = user
    context["agenda"] = agenda

    def render_addvote(context, status_code):
        """ Simplify render_template a bit for this test. """
        return render_template(request, "addvote.html", context, status_code)

    # Alright, get some database records, if we can.
    if type != "bylaws":
271
272
        user_record = db.query(models.User).filter(
            models.User.Username == user).first()
273
274
275
276
        if user_record is None:
            context["error"] = "Username does not exist."
            return render_addvote(context, HTTPStatus.NOT_FOUND)

277
278
        voteinfo = db.query(models.TUVoteInfo).filter(
            models.TUVoteInfo.User == user).count()
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
        if voteinfo:
            _ = l10n.get_translator_for_request(request)
            context["error"] = _(
                "%s already has proposal running for them.") % (
                html.escape(user),)
            return render_addvote(context, HTTPStatus.BAD_REQUEST)

    if type not in ADDVOTE_SPECIFICS:
        context["error"] = "Invalid type."
        context["type"] = type = "add_tu"  # Default for rendering.
        return render_addvote(context, HTTPStatus.BAD_REQUEST)

    if not agenda:
        context["error"] = "Proposal cannot be empty."
        return render_addvote(context, HTTPStatus.BAD_REQUEST)

    # Gather some mapped constants and the current timestamp.
    duration, quorum = ADDVOTE_SPECIFICS.get(type)
297
    timestamp = time.utcnow()
298

299
    # Active TU types we filter for.
300
301
    types = {TRUSTED_USER_ID, TRUSTED_USER_AND_DEV_ID}

302
    # Create a new TUVoteInfo (proposal)!
303
    with db.begin():
304
305
306
307
308
309
310
        active_tus = db.query(User).filter(
            and_(User.Suspended == 0,
                 User.InactivityTS.isnot(None),
                 User.AccountTypeID.in_(types))
        ).count()
        voteinfo = db.create(models.TUVoteInfo, User=user,
                             Agenda=html.escape(agenda),
311
312
                             Submitted=timestamp, End=(timestamp + duration),
                             Quorum=quorum, ActiveTUs=active_tus,
313
                             Submitter=request.user)
314
315

    # Redirect to the new proposal.
316
317
    endpoint = f"/tu/{voteinfo.ID}"
    return RedirectResponse(endpoint, status_code=HTTPStatus.SEE_OTHER)