trusted_user.py 11.7 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
13
from aurweb.models import User
from aurweb.models.account_type import TRUSTED_USER_AND_DEV_ID, TRUSTED_USER_ID
14
from aurweb.templates import make_context, make_variable_context, render_template
Kevin Morris's avatar
Kevin Morris committed
15
16

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

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

23
24
25
26
27
28
29
30
31
32
33
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
34
35

@router.get("/tu")
36
@requires_auth
Kevin Morris's avatar
Kevin Morris committed
37
38
39
40
41
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
42
43
44
    if not request.user.has_credential(creds.TU_LIST_VOTES):
        return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)

Kevin Morris's avatar
Kevin Morris committed
45
46
47
48
49
50
51
52
    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

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

    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

65
66
67
    current_votes = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.End > ts).order_by(
        models.TUVoteInfo.Submitted.desc())
Kevin Morris's avatar
Kevin Morris committed
68
69
70
71
72
73
    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

74
75
76
    past_votes = db.query(models.TUVoteInfo).filter(
        models.TUVoteInfo.End <= ts).order_by(
        models.TUVoteInfo.Submitted.desc())
Kevin Morris's avatar
Kevin Morris committed
77
78
79
80
81
82
    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

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

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

    return render_template(request, "tu/index.html", context)
111
112


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

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

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

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

150
151
152
    context = await make_variable_context(request, "Trusted User")
    proposal = int(proposal)

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

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

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

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

189
190
191
192
193
    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()
194
195

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

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

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


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

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

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

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

296
    # Active TU types we filter for.
297
298
    types = {TRUSTED_USER_ID, TRUSTED_USER_AND_DEV_ID}

299
    # Create a new TUVoteInfo (proposal)!
300
    with db.begin():
301
302
303
304
305
306
307
        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),
308
309
                             Submitted=timestamp, End=(timestamp + duration),
                             Quorum=quorum, ActiveTUs=active_tus,
310
                             Submitter=request.user)
311
312

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