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

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

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

11
from aurweb import db, l10n, logging, models, time
12
from aurweb.auth import creds, requires_auth
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
84
85
86
87
    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

    # TODO
    # We order last votes by TUVote.VoteID and User.Username.
    # This is really bad. We should add a Created column to
    # TUVote of type Timestamp and order by that instead.
88
89
90
91
92
93
94
95
    last_votes_by_tu = db.query(models.TUVote).filter(
        and_(models.TUVote.VoteID == models.TUVoteInfo.ID,
             models.TUVoteInfo.End <= ts,
             models.TUVote.UserID == models.User.ID,
             or_(models.User.AccountTypeID == 2,
                 models.User.AccountTypeID == 4))
    ).group_by(models.User.ID).order_by(
        models.TUVote.VoteID.desc(), models.User.Username.asc())
Kevin Morris's avatar
Kevin Morris committed
96
97
98
99
100
    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"

101
102
103
104
105
106
    context["q"] = {
        "coff": current_off,
        "cby": current_by,
        "poff": past_off,
        "pby": past_by
    }
Kevin Morris's avatar
Kevin Morris committed
107
108

    return render_template(request, "tu/index.html", context)
109
110


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

121
122
    total = voteinfo.total_votes()
    participation = (total / voteinfo.ActiveTUs) if total else 0
123
124
125
126
127
128
    context["participation"] = participation

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

129
    can_vote = voters.filter(models.TUVote.User == request.user).first() is None
130
131
132
133
134
135
136
137
138
139
140
141
142
    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}")
143
@requires_auth
144
async def trusted_user_proposal(request: Request, proposal: int):
145
146
147
    if not request.user.has_credential(creds.TU_LIST_VOTES):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

148
149
150
    context = await make_variable_context(request, "Trusted User")
    proposal = int(proposal)

151
152
    voteinfo = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.ID == proposal).first()
153
    if not voteinfo:
154
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
155

156
157
158
159
160
    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()
161
    if not request.user.has_credential(creds.TU_VOTE):
162
        context["error"] = "Only Trusted Users are allowed to vote."
163
    if voteinfo.User == request.user.Username:
164
165
166
167
168
169
170
171
172
        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}")
173
@requires_auth
174
async def trusted_user_proposal_post(request: Request, proposal: int,
175
                                     decision: str = Form(...)):
176
177
178
    if not request.user.has_credential(creds.TU_LIST_VOTES):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

179
180
181
    context = await make_variable_context(request, "Trusted User")
    proposal = int(proposal)  # Make sure it's an int.

182
183
    voteinfo = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.ID == proposal).first()
184
    if not voteinfo:
185
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
186

187
188
189
190
191
    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()
192
193

    status_code = HTTPStatus.OK
194
    if not request.user.has_credential(creds.TU_VOTE):
195
196
197
198
199
        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
200
    elif vote is not None:
201
202
203
204
205
206
207
208
209
210
211
212
213
        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.",
214
                        status_code=HTTPStatus.BAD_REQUEST)
215

216
    with db.begin():
217
        vote = db.create(models.TUVote, User=request.user, VoteInfo=voteinfo)
218
        voteinfo.ActiveTUs += 1
219
220
221

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


@router.get("/addvote")
225
@requires_auth
226
227
228
229
230
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)

231
232
233
234
235
236
237
238
239
240
241
242
243
244
    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")
245
@requires_auth
246
247
248
249
async def trusted_user_addvote_post(request: Request,
                                    user: str = Form(default=str()),
                                    type: str = Form(default=str()),
                                    agenda: str = Form(default=str())):
250
251
252
    if not request.user.has_credential(creds.TU_ADD_VOTE):
        return RedirectResponse("/tu", status_code=HTTPStatus.SEE_OTHER)

253
254
255
256
257
258
259
260
261
262
263
264
265
    # 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":
266
267
        user_record = db.query(models.User).filter(
            models.User.Username == user).first()
268
269
270
271
        if user_record is None:
            context["error"] = "Username does not exist."
            return render_addvote(context, HTTPStatus.NOT_FOUND)

272
273
        voteinfo = db.query(models.TUVoteInfo).filter(
            models.TUVoteInfo.User == user).count()
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
        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)
292
    timestamp = time.utcnow()
293

294
    # TODO: Review this. Is this even necessary?
295
296
297
298
    # Remove <script> and <style> tags.
    agenda = re.sub(r'<[/]?script.*>', '', agenda)
    agenda = re.sub(r'<[/]?style.*>', '', agenda)

299
300
301
302
303
304
305
    types = {TRUSTED_USER_ID, TRUSTED_USER_AND_DEV_ID}
    active_tus = db.query(User).filter(
        and_(User.Suspended == 0,
             User.InactivityTS.isnot(None),
             User.AccountTypeID.in_(types))
    ).count()

306
    # Create a new TUVoteInfo (proposal)!
307
    with db.begin():
308
309
310
        voteinfo = db.create(models.TUVoteInfo, User=user, Agenda=agenda,
                             Submitted=timestamp, End=(timestamp + duration),
                             Quorum=quorum, ActiveTUs=active_tus,
311
                             Submitter=request.user)
312
313

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