views.py 40.8 KB
Newer Older
ViolanteCodes's avatar
ViolanteCodes committed
1
from __future__ import absolute_import
2
import gitlab
3
import functools
4
from django.conf import settings
5
from anonticket.models import (
6
    UserIdentifier, Project, Issue, Note, GitlabAccountRequest)
ViolanteCodes's avatar
ViolanteCodes committed
7
8
from .forms import (
    Anonymous_Ticket_Project_Search_Form, 
9
    LoginForm,
ViolanteCodes's avatar
ViolanteCodes committed
10
    CreateIssueForm,
11
    )
12
13
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse, reverse_lazy
14
from django.core.exceptions import ObjectDoesNotExist
15
from django.utils.decorators import method_decorator
16
from django.contrib.auth.decorators import user_passes_test
17
18
from django.views.generic import (
    TemplateView, DetailView, ListView, CreateView, FormView, UpdateView)
ViolanteCodes's avatar
ViolanteCodes committed
19
from django.contrib.admin.views.decorators import staff_member_required
20
from ratelimit.decorators import ratelimit
ViolanteCodes's avatar
ViolanteCodes committed
21
22
23
24
from functools import wraps
from ratelimit import UNSAFE
from ratelimit.exceptions import Ratelimited
from ratelimit.core import is_ratelimited
ViolanteCodes's avatar
ViolanteCodes committed
25

26
27
28
29
# ---------------SHARED FUNCTIONS, NON GITLAB---------------------------
# Functions that need to be accessed from within multiple views go here,
# with the exception of gitlab functions, which are below.
# ----------------------------------------------------------------------
30

31
32
33
# Set WORD_LIST_CONTENT as global variable with value of None.
WORD_LIST_CONTENT = None

34
35
36
37
38
39
40
41
42
43
def get_wordlist():
    """Returns the wordlist. If wordlist_as_list is not already in memory,
    fetches the wordlist from file and creates it."""
    global WORD_LIST_CONTENT
    if WORD_LIST_CONTENT is None:
        word_list_path = settings.WORD_LIST_PATH
        with open(word_list_path) as f:
            wordlist_as_list = f.read().splitlines()
    return wordlist_as_list  

44
def user_identifier_in_database(find_user):
45
    """See if user_identifier is in database. Returns True/False."""
46
47
    # Try to find the user in the database.
    try:
48
        user_to_find = UserIdentifier.objects.get(user_identifier=find_user)
49
        # if found, update the user_found message
50
        user_found = True
51
52
    except:
        # if user is not found, return user_not_found message
53
        user_found = False
54
55
    return user_found

56
57
58
59
60
61
62
63
64
65
def get_user_as_object(find_user):
    """Gets the User Identifier from the database. Should only be used if User Identifier exists."""
    user_to_find = UserIdentifier.objects.get(user_identifier=find_user)
    return user_to_find

def get_linked_issues(UserIdentifier):
    """Gets a list of the issues assigned to a User Identifier."""
    linked_issues = Issue.objects.filter(linked_user=UserIdentifier)
    return linked_issues

66
67
68
69
70
def get_linked_notes(UserIdentifier):
    """Gets a list of the notes assigned to a User Identifier."""
    linked_notes = Note.objects.filter(linked_user=UserIdentifier)
    return linked_notes

71
72
def check_user(user_identifier):
    """Check that a user_identifier meets validation requirements."""
73
    user_identifier = user_identifier.lower()
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
    id_to_test = user_identifier.split('-')
    if len(id_to_test) != settings.DICE_ROLLS:
        return False
    # Check that all words in code-phrase are unique
    set_id_to_test = set(id_to_test)
    if len(set_id_to_test) != len(id_to_test):
        return False
    # Grab the word_list from the path specified in settings.py
    wordlist_as_list = get_wordlist()
    # Check that all words are in the dictionary.
    check_all_words = all(item in wordlist_as_list for item in id_to_test)
    if check_all_words == False:
        return False
    else:
        return True
89
90
# --------------------DECORATORS AND MIXINS-----------------------------
# Django decorators wrap functions (such as views) in other functions. 
ViolanteCodes's avatar
ViolanteCodes committed
91
92
# Mixins perform a similar function for class based views. 
# Decorates for rate-limiting are below in RATE-LIMITING SETTINGS.
93
94
95
# ----------------------------------------------------------------------

def validate_user(view_func):
ViolanteCodes's avatar
ViolanteCodes committed
96
    """A decorator that calls check_user validator."""
97
98
    @functools.wraps(view_func)
    def validate_user_identifier(request, user_identifier, *args, **kwargs):
99
100
        lowercase_user_identifier = user_identifier.lower()
        get_user_identifier = check_user(lowercase_user_identifier)
101
        if get_user_identifier == False:
ViolanteCodes's avatar
ViolanteCodes committed
102
            return redirect('user-login-error', user_identifier=lowercase_user_identifier)
103
        else:
104
            response = view_func(request, lowercase_user_identifier, *args, **kwargs)
105
106
107
        return response
    return validate_user_identifier

108
class PassUserIdentifierMixin:
109
110
111
112
113
    """Mixin that passes user_identifier from CBV kwargs to view
    context in a 'results' dictionary, which allows it to be called in template
    with results.user_identifier (same as FBV)."""
    def get_context_data(self, **kwargs):          
        context = super().get_context_data(**kwargs)
114
        if 'user_identifier' in self.kwargs:
115
            context['results'] = {'user_identifier':self.kwargs['user_identifier']}                     
116
        return context
117

118
119
120
121
122
123
# --------------------RATE-LIMITING SETTINGS----------------------------
# Set variables here so that django-ratelimit settings can be 
# changed across multiple views.
# ----------------------------------------------------------------------

# All items (groups/issues) currently share from same rate-limiting
124
125
# bucket, which is set by RATE_GROUP. You can change this by using a 
# different group name in the decorator or by not including a group name 
ViolanteCodes's avatar
ViolanteCodes committed
126
127
# with requests. The custom decorators below allow rate-limiting by 
# IP and by key:post with Tor preferred settings applied.
128

129
RATE_GROUP = settings.MAIN_RATE_GROUP
130
BLOCK_ALL = settings.BLOCK_ALL
131

ViolanteCodes's avatar
ViolanteCodes committed
132
133
134
135
136
137
def custom_ratelimit_ip(
    group=RATE_GROUP, 
    key='ip', 
    rate=None,  
    method=ratelimit.UNSAFE, 
    block=True, 
138
    block_all = BLOCK_ALL,
ViolanteCodes's avatar
ViolanteCodes committed
139
    ):
ViolanteCodes's avatar
ViolanteCodes committed
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
    """Custom version of the @ratelimit decorator based on key='ip' and 
    callable rate function."""

    __all__ = ['ratelimit']
    ratelimit.UNSAFE = UNSAFE

    def get_rate_limit(group, request, block_all=False):
        """Callable function to get rate limit to make testing easier."""
        if block_all == True:
            return '0/s'
        else:
            return settings.LIMIT_RATE

    def decorator(fn):
        @wraps(fn)
        def _wrapped(request, *args, **kw):
            old_limited = getattr(request, 'limited', False)
            ratelimited = is_ratelimited(
                request=request, 
                group=group, 
                fn=fn,
                key=key, 
                rate=get_rate_limit(
                    group=group, request=request, block_all=block_all), 
                method=method,
                increment=True)
            request.limited = ratelimited or old_limited
            if ratelimited and block:
                raise Ratelimited()
            return fn(request, *args, **kw)
        return _wrapped
    return decorator

def custom_ratelimit_post(
    group=RATE_GROUP, 
    key='post:', 
    rate=None,  
    method=ratelimit.UNSAFE, 
    block=True, 
179
    block_all = BLOCK_ALL,
ViolanteCodes's avatar
ViolanteCodes committed
180
181
182
183
184
185
    ):
    """Custom version of the @ratelimit decorator with key='post' and
    callable rate function."""

    __all__ = ['ratelimit']
    ratelimit.UNSAFE = UNSAFE
ViolanteCodes's avatar
ViolanteCodes committed
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212

    def get_rate_limit(group, request, block_all=False):
        """Callable function to get rate limit to make testing easier."""
        if block_all == True:
            return '0/s'
        else:
            return settings.LIMIT_RATE

    def decorator(fn):
        @wraps(fn)
        def _wrapped(request, *args, **kw):
            old_limited = getattr(request, 'limited', False)
            ratelimited = is_ratelimited(
                request=request, 
                group=group, 
                fn=fn,
                key=key, 
                rate=get_rate_limit(
                    group=group, request=request, block_all=block_all), 
                method=method,
                increment=True)
            request.limited = ratelimited or old_limited
            if ratelimited and block:
                raise Ratelimited()
            return fn(request, *args, **kw)
        return _wrapped
    return decorator
213

214
215
216
# ------------------SHARED FUNCTIONS, GITLAB---------------------------
# Easy to parse version of GitLab-Python functions.
# ----------------------------------------------------------------------
217
218
gl = gitlab.Gitlab(settings.GITLAB_URL, private_token=settings.GITLAB_SECRET_TOKEN, timeout=1)
gl_public = gitlab.Gitlab(settings.GITLAB_URL, timeout=1)
219

220
def gitlab_get_project(project, lazy=False, public=False):
221
    """Takes an integer, and grabs a gitlab project where gitlab_id
222
    matches the integer."""
223
    # choose the appropriate gl_object based on public API or token
224
    if public == True:
225
        gl_object = gl_public
226
    else:
227
        gl_object = gl
228
    # Fetch project with lazy == lazy.
229
    working_project = gl_object.projects.get(project, lazy=lazy)
230
231
        return working_project
    
232
def gitlab_get_issue(project, issue, lazy_project=False, lazy_issue=False, public=False):
233
    """Takes two integers and grabs corresponding gitlab issue."""
234
    working_project = gitlab_get_project(project, lazy=lazy_project, public=public)
235
    working_issue = working_project.issues.get(issue, lazy=lazy_issue)
236
237
238
239
240
241
242
    return working_issue

def gitlab_get_notes_list(project, issue):
    """Grabs the notes list for a specific issue."""
    working_issue = gitlab_get_issue(project, issue)
    notes_list = working_issue.notes.list()
    return notes_list
243

244
# --------------------------SPECIFIC VIEWS------------------------------
245
# The functions below are listed in the order that a user is likely to 
246
247
# encounter them (e.g., generate a codename, then login with codename.)
# ----------------------------------------------------------------------
248
#
249
250
251
# -------------IDENTIFIER, LOGIN, ACCOUNT REQUEST VIEWS-----------------
# Initial views related to landing, user_identifier, and gitlab account
# requests for users.
252
# ----------------------------------------------------------------------
253

254
class CreateIdentifierView(TemplateView):
ViolanteCodes's avatar
ViolanteCodes committed
255
256
    """View that randomly samples a word_list and passes the
    new user_identifier as string into a context dictionary."""
257
258
259

    template_name = "anonticket/create_identifier.html"
    
260
    def generate_user_identifier_list(self):
261
262
263
        """Randomly samples X words from word_list."""
        # Import the random module
        import random
264
        word_list = get_wordlist()
265
266
        # Use random.sample to pull x words from word_list, where number of 
        # dice is = to settings.DICE_ROLLS. 
267
268
269
270
271
272
        user_list = random.sample(word_list, settings.DICE_ROLLS)
        return user_list

    def context_dict(self, word_list=[]):
        """Build the context dictionary."""
        context = {}
273
        # pass the wordlist into the context dictionary with key 'chosen_words'
274
275
        context['chosen_words'] = word_list
        join_character = '-'
276
        # Join the list with join_character and save as user_identifier_string
277
        user_identifier_string = join_character.join(word_list)
278
        # Pass the string into the context dictionary
279
        context['user_identifier_string'] = user_identifier_string
280
        # return context dictionary to use in template
281
282
283
284
285
        return context

    def get_context_data(self):
        """Fetch wordlist, pull out words at random, convert to dictionary
        for rendering in django template."""
286
        # Call the get_wordlist function and save as word_list
287
        word_list = get_wordlist()
288
289
        # Start a while loop
        while True:
290
        # Call the generate_user_identifier_list function and save as chosen_words
291
            chosen_words = self.generate_user_identifier_list()
292
293
294
295
296
297
298
299
300
301
            # Call the function that generates the context dictionary to return to template.
            context = self.context_dict(word_list=chosen_words)
            # Check if user already exists in the database
            user_found = user_identifier_in_database(context['user_identifier_string'])
            # if user is unique, return the context dictionary to the template and render
            if user_found == False:
                return context
            # if user is not unique, re-enter the loop
            else: 
                continue
302

303
def login_view(request):
ViolanteCodes's avatar
ViolanteCodes committed
304
    """Generate a login form. Note that most processing for this view is in forms.py"""
305
306
    form = LoginForm(request.GET)
    if form.is_valid():
307
        # if there is a user_identifier in the form keys (sanitized login_string field):
308
309
        if 'user_identifier' in form.cleaned_data.keys():
            user_identifier = form.cleaned_data['user_identifier']
310
            # redirect to user-landing view, passing user_identifier to url
311
312
            return redirect('user-landing', user_identifier = user_identifier)
        else:
313
314
            return render (request, 'anonticket/user_login.html', {'form':form})
    # if no valid user_identifier in GET, just display the form
315
    else: 
316
        return render (request, 'anonticket/user_login.html', {'form': form})
317
    # If everything else fails, just render the form.
318
    return render (request, 'anonticket/user_login.html', {'form':form})
319

320
@validate_user
321
def user_landing_view(request, user_identifier):
ViolanteCodes's avatar
ViolanteCodes committed
322
323
324
    """The 'landing page'. Checks if user_identifier is in database and 
    passes user_found = True to context dictionary if found, along
    with any issues or comments created by user."""
325
    results = {}
326
    # Check that entered User Identifier meets validation
327
328
329
330
331
332
333
334
    user_found = user_identifier_in_database(user_identifier)
    # if user is found, pass 'user_found' to context dictionary
    if user_found == True:
        results['user_found'] = user_found
        # Get linked issues linked to this user identifier and pass into 
        # results dictionary.
        working_user = get_user_as_object(user_identifier)
        linked_issues = get_linked_issues(working_user)
335
        # Create a list of issues passed as dicts with urls generated
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
        results['linked_issues'] = []
        for issue in linked_issues:
            # if the issue has a gitlab_iid, generate a link to issue-detail-view
            if issue.gitlab_iid:
                issue_url = reverse(
                    'issue-detail-view', args = [
                        working_user, issue.linked_project.slug, issue.gitlab_iid]
                )
                results['linked_issues'].append(
                    {
                        'attributes': issue,
                        'issue_url': issue_url
                    }
                )
            else:
                issue_url = reverse(
                    'pending-issue-detail-view', args = [
                        working_user, issue.linked_project.slug, issue.pk]
                )
                results['linked_issues'].append(
                    {
                        'attributes': issue,
                        'issue_url': issue_url
                    }
                )
361
362
363
364
365
366
367
368
369
370
371
372
        linked_notes = get_linked_notes(working_user)
        results['linked_notes'] = []
        for note in linked_notes:
        # if the issue has a gitlab_iid, generate a link to issue-detail-view
            if note.gitlab_id:
                note_url = reverse(
                    'issue-detail-view', args = [
                        working_user, note.linked_project.slug, note.issue_iid]
                )
                results['linked_notes'].append(
                    {
                        'attributes': note,
373
374
                        'note_url': note_url,
                        'link_text': "(Posted To Issue.)"
375
376
377
378
379
380
381
382
383
384
                    }
                )
            else:
                note_url = reverse(
                    'pending-note', args = [
                        working_user, note.linked_project.slug, note.issue_iid, note.pk]
                )
                results['linked_notes'].append(
                    {
                        'attributes': note,
385
386
                        'note_url': note_url,
                        'link_text': "(See full note text.)"
387
388
                    }
                )        
389
    # whether user found or not found, pass 'user_identifier' to context dictionary
390
    results['user_identifier'] = user_identifier
391
392
393
394
395
    return render(request, 'anonticket/user_landing.html', {'results': results})

class UserLoginErrorView(TemplateView):
    """A generic landing page if a username doesn't pass validation tests."""
    template_name = 'anonticket/user_login_error.html'
396

397
398
class GitlabAccountRequestCreateView(
    PassUserIdentifierMixin, CreateView):
399
400
401
402
403
404
405
    """A view for users to create gitlab account requests."""
    model = GitlabAccountRequest
    fields = [
        'username', 
        'email', 
        'reason',
        ]
406
407
    template_name_suffix = '_user_create'

408
    def form_valid(self, form):
409
410
        """Populate user_identifier FK from URL if present, without allowing duplicate
        pending requests."""
ViolanteCodes's avatar
ViolanteCodes committed
411
412
        # If user_identifier in the URL path, set variable user_identifier.
        if 'user_identifier' in self.kwargs:
413
            user_identifier = self.kwargs['user_identifier']
ViolanteCodes's avatar
ViolanteCodes committed
414
415
416
            # make sure it's a valid user_identifier
            if check_user(user_identifier) == False:
                return redirect('user-login-error', user_identifier=user_identifier)
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
             # Then try to grab the user_identifier from URL from database.
            try: 
                url_user = UserIdentifier.objects.get(user_identifier=user_identifier)
                # if the User Identifier exists, see if has made any account requests
                gl_requests = GitlabAccountRequest.objects.filter(linked_user=url_user)
                if len(gl_requests) != 0:
                    for request in gl_requests:
                        if request.reviewer_status == 'P':
                            return redirect('cannot-create-with-user', user_identifier=user_identifier)
                        # If you get this far, save the form with the new account request.
                        form.instance.linked_user = url_user
                 # if the User Identifier exists, but does not have a pending GL request, make one.
                else:
                    form.instance.linked_user = url_user
            # If the user_identifier doesn't exist, create it and save the request.
            except ObjectDoesNotExist:
                new_user = UserIdentifier(user_identifier=user_identifier)
                new_user.save()
                form.instance.linked_user = new_user
436
437
438
        # Either way, return the valid form.
        return super(GitlabAccountRequestCreateView, self).form_valid(form)
    
439
440
441
442
443
444
445
446
    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        working_object = self.object
        if working_object.linked_user:
            working_url = reverse('issue-created', args=[working_object.linked_user])
        else:
            working_url = reverse('created-no-user')
        return working_url
447

ViolanteCodes's avatar
ViolanteCodes committed
448
449
450
class CannotCreateObjectView(PassUserIdentifierMixin, TemplateView):
    """"""
    template_name = 'anonticket/cannot_create.html'
ViolanteCodes's avatar
ViolanteCodes committed
451
452
453
454
455
456
457
# -------------------------PROJECT VIEWS----------------------------------
# Views related to creating/looking up issues.
# ----------------------------------------------------------------------

@method_decorator(validate_user, name='dispatch')
class ProjectListView(PassUserIdentifierMixin, ListView):
    """Simple List View of all projects."""
458
    queryset = Project.objects.order_by('name_with_namespace')
459

460
@method_decorator(validate_user, name='dispatch')
461
class ProjectDetailView(DetailView):
462
    """A detail view of a single project, which also validates user_identifier
463
    and fetches the project and issues from gitlab."""
464
465
    model = Project

466
    def get_context_data(self, **kwargs):
467
        # Grab variables from kwargs          
468
        context = super().get_context_data(**kwargs)
469
        user_identifier = self.kwargs['user_identifier']
470
        page_number = self.kwargs['page_number']
471
        project_slug = self.kwargs['slug']
472
        # Fetch the project from the database                   
473
        db_project = Project.objects.get(
474
475
            slug=project_slug
        )
476
477
        # Grab the gitlab ID from db and create GL project object with
        # a lazy API call.
478
        gitlab_id = db_project.gitlab_id
479
        gl_project = gitlab_get_project(gitlab_id, lazy=True, public=True)
480
        # Save the project attributes to context dict.
481
482
        context['results'] = {'user_identifier': user_identifier}
        context['page_number'] = page_number
483
        context['gitlab_project'] = gl_project.attributes
484
        # Get the open issues and save to dict.
485
        context['open_issues']={}
486
        check_open = self.get_pagination(
487
488
489
            user_identifier, project_slug, gl_project, page_number, issue_state='opened')
        for key, value in check_open.items():
            context['open_issues'][key] = value
490
        # Get the closed issues aqnd save to dict.
ViolanteCodes's avatar
ViolanteCodes committed
491
492
493
494
495
496
        context['closed_issues'] = {}
        check_closed = self.get_pagination(
            user_identifier, project_slug, gl_project, page_number, issue_state='closed'
        )
        for key, value in check_closed.items():
            context['closed_issues'][key] = value
497
        return context
498

499
    def get_pagination(
ViolanteCodes's avatar
ViolanteCodes committed
500
501
502
        self, user_identifier, project_slug, gl_project, current_page, issue_state
        ):
        result_dict = {}
503
504
        result_dict['issues']={}
        #grab issues for current page from gitlab
505
        issues_list = gl_project.issues.list(page=current_page, state=issue_state, lazy=True)
506
507
508
509
510
511
512
        # generate detail_links that will return to current page.
        for issue in issues_list:
            detail_url = reverse('issue-detail-view-go-back', args=[
                user_identifier, project_slug, issue.iid, current_page
            ])
            # and add them to issues in result dict in key/value pairs.
            result_dict['issues'][issue] = detail_url
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
        # call generator get total_pages and total_issues.
        call_generator = gl_project.issues.list(as_list=False, state=issue_state)
        total_pages = call_generator.total_pages
        total_issues = call_generator.total
        result_dict['total_pages'] = total_pages
        result_dict['total_issues'] = total_issues

        # if current_page > 1, create a "prev" link for button
        if current_page > 1:
            result_dict['prev_url'] = self.make_prev_link(
                current_page, user_identifier, project_slug
            )

        # if total_pages > current_page, create "next" link for button
        if total_pages > current_page:
            result_dict['next_url'] = self.make_next_link(
                current_page, user_identifier, project_slug)
530
        
531
532
533
534
535
536
537
538
        # if current_page > total pages, render current page to up - 10 slots
        if current_page > total_pages:
            # calculate how many links need to be rendered before 
            # the current page
            prev_page_start = total_pages - 10
            if prev_page_start > 0:
                result_dict['first_url'] = self.make_first_link(
                user_identifier, project_slug)
ViolanteCodes's avatar
ViolanteCodes committed
539
            # Don't create links where page_start would be < 0.
540
541
542
543
544
545
            if prev_page_start < 0:
                prev_page_start = 0
            # make all prev_links
            result_dict['prev_pages'] = self.make_all_prev_links(
                prev_page_start, total_pages, user_identifier, project_slug)

546
        # if total_pages <= 10, render everything.
547
        elif total_pages <= 10:
548
        # make all prev_links
549
550
            result_dict['prev_pages'] = self.make_all_prev_links(
                0, current_page, user_identifier, project_slug)
551
        # make all post_links
552
553
            result_dict['post_pages'] = self.make_all_post_links(
                current_page, total_pages, user_identifier, project_slug)
554

555
556
        # if total pages > 10, then use the current_page to determine how many
        # pages to render before and after.
557
        elif current_page <= 9:
558
            # make all prev_links
559
560
            result_dict['prev_pages'] = self.make_all_prev_links(
                0, current_page, user_identifier, project_slug)
561
            # make all post_links UP TO 10
562
            result_dict['post_pages'] = self.make_all_post_links(
563
564
565
566
                current_page, 9, user_identifier, project_slug)
            # make a last link
            result_dict['last_page'] = self.make_last_link(
                user_identifier, project_slug, total_pages
567
            )
568

ViolanteCodes's avatar
ViolanteCodes committed
569
        # if current page is less than 5 pages from the end, calculate
570
        # how many pages to show before and after.
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
        elif (total_pages - current_page) < 5:
            # calculate how many pages will be rendered after current
            post_pages = total_pages - current_page
            # calculate how many links need to be rendered before 
            # the current page
            prev_page_start = current_page - (9 - post_pages)
            # make all prev_links
            result_dict['prev_pages'] = self.make_all_prev_links(
                prev_page_start, current_page, user_identifier, project_slug)
            # make all post_links
            result_dict['post_pages'] = self.make_all_post_links(
                current_page, total_pages, user_identifier, project_slug)
            # make a first link
            result_dict['first_url'] = self.make_first_link(
                user_identifier, project_slug)
586

587
        else:
588
            # calculate how many pages will be rendered after current
589
            post_pages = current_page + 3
590
            # calculate how many links will be rendered before current
591
            prev_page_start = current_page - 4
592
593
594
595
596
597
598
            # make all prev_links
            result_dict['prev_pages'] = self.make_all_prev_links(
                prev_page_start, current_page, user_identifier, project_slug)
            # make all post_links
            result_dict['post_pages'] = self.make_all_post_links(
                current_page, post_pages, user_identifier, project_slug)
            # make a first link
599
            result_dict['first_url'] = self.make_first_link(
600
601
602
603
604
                user_identifier, project_slug)
            # make a last link
            result_dict['last_page'] = self.make_last_link(
                user_identifier, project_slug, total_pages
            )
605
        return result_dict
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624

    def make_prev_link(self, current_page, user_identifier, project_slug):
        """Creates the 'prev' button link for issue pagination."""
        prev_page = current_page - 1
        prev_url = reverse(
            'project-detail', args=[
                user_identifier, project_slug, prev_page
            ]
        )
        return prev_url   

    def make_next_link(self, current_page, user_identifier, project_slug):
        """Creates the 'next' button link for issue pagination."""
        next_page = current_page + 1
        next_url = reverse(
            'project-detail', args=[
                user_identifier, project_slug, next_page
            ]
        )
625
        return next_url
626
627
628
629
630
631
632
633
634
635
    
    def make_first_link(
        self, user_identifier, project_slug):
        """Create links for a "first" page."""
        first_url = reverse(
            'project-detail', args=[
                user_identifier, project_slug, 1
            ]
        )
        return first_url
636

637
    def make_last_link(
638
639
        self, user_identifier, project_slug, last_page):
        """Create link for a "last" page."""
640
        results = {}
641
642
643
644
645
        last_url = reverse(
            'project-detail', args=[
                user_identifier, project_slug, last_page
            ]
        )
646
647
648
        results['page_number'] = last_page
        results['url'] = last_url
        return results
649
    
650
    def make_all_prev_links(self, start_page, current_page, user_identifier, project_slug):
651
652
        """Create links and page numbers for pages before current page."""
        results = {}
653
        starting_page = start_page
654
        while starting_page < (current_page - 1):
655
656
657
658
659
660
661
662
663
664
665
            starting_page += 1
            page_number = starting_page
            page_url = reverse(
                'project-detail', args=[
                    user_identifier, project_slug, page_number
                ]
            )
            results[page_number] = page_url
        return results

    def make_all_post_links(
666
        self, current_page, end_page, user_identifier, project_slug):
667
668
669
        """Create links and page numbers for pages after current page."""
        results = {}
        starting_page = current_page
670
        while starting_page < end_page:
671
672
673
674
675
676
677
678
679
            starting_page += 1
            page_number = starting_page
            page_url = reverse(
                'project-detail', args=[
                    user_identifier, project_slug, page_number
                ]
            )
            results[page_number] = page_url
        return results
680

ViolanteCodes's avatar
ViolanteCodes committed
681
# -------------------------ISSUE VIEWS----------------------------------
682
683
684
# Views related to creating/looking up issues.
# ----------------------------------------------------------------------

685
@validate_user
ViolanteCodes's avatar
ViolanteCodes committed
686
687
@custom_ratelimit_post()
@custom_ratelimit_ip()
688
def create_issue_view(request, user_identifier, *args):
689
    """View that allows a user to create an issue. Pulls the user_identifier
ViolanteCodes's avatar
ViolanteCodes committed
690
    from the URL path and tries to pull that UserIdentifier from database, 
691
692
    creating it if this is the user's first action."""
    results = {}
693
    results['user_identifier'] = user_identifier
694
695
696
697
698
699
700
    user_to_retrieve = user_identifier
    if request.method == 'POST':
        form = CreateIssueForm(request.POST)
        if form.is_valid():
            # Create the issue, but don't save it yet, since we need to get
            # the user_identifier.
            issue_without_user = form.save(commit=False)
701
702
703
            # Add the user_identifier to the context dictionary to be passed to
            # redirect upon success.
            results['user_identifier'] = user_identifier
704
705
706
707
708
709
710
            # Try to find the user_identifier in database. 
            try: 
                current_user = UserIdentifier.objects.get(user_identifier=user_to_retrieve)
            # If lookup fails, create the user.
            except: 
                current_user = UserIdentifier(user_identifier=user_to_retrieve)
                current_user.save()
711
            # Assign the user_identifier to the issue and then same the issue. 
712
713
714
            issue_without_user.linked_user = current_user
            # Save the new issue. 
            issue_without_user.save()
715
            return redirect('issue-created', user_identifier)
716
717
    else:
        form = CreateIssueForm
718
    return render(request, 'anonticket/create_new_issue.html', {'form':form, 'results':results})
719

720
@method_decorator(validate_user, name='dispatch')
ViolanteCodes's avatar
ViolanteCodes committed
721
class IssueSuccessView(PassUserIdentifierMixin, TemplateView):
722
    """View that tells the user their issue was successfully created."""
723
    template_name = 'anonticket/create_issue_success.html'
724

725
726
727
728
729
class ObjectCreatedNoUserView(TemplateView):
    """View that tells the user their issue was successfully created. Use
    when creation request was anonymous."""
    template_name = 'anonticket/create_issue_success.html'

730
@method_decorator(validate_user, name='dispatch')
731
class PendingIssueDetailView(PassUserIdentifierMixin, DetailView):
ViolanteCodes's avatar
ViolanteCodes committed
732
    """View for pending issues that have not been mod approved."""
733
734
735
    model = Issue
    template_name = 'anonticket/issue_pending.html'

736
@validate_user
737
def issue_detail_view(request, user_identifier, project_slug, gitlab_iid, go_back_number=1):
ViolanteCodes's avatar
ViolanteCodes committed
738
    """Detailed view of an issue that has been approved and posted to GL."""
739
    results = {}
740
741
    #Fetch project from database via slug in URL - if cannot be fetched,
    # return a 404 error.
ViolanteCodes's avatar
ViolanteCodes committed
742
    database_project = get_object_or_404(Project, slug=project_slug)
743
    results['user_identifier']=user_identifier
744
745
    # Use the gitlab_id from database project to fetch project from gitlab.
    # (Increases security.)
ViolanteCodes's avatar
ViolanteCodes committed
746
    gitlab_id = database_project.gitlab_id
747
    working_project = gitlab_get_project(project=gitlab_id, public=True)
748
    results['project'] = working_project.attributes
749
    go_back_url = reverse('project-detail', args=[user_identifier, project_slug, go_back_number])
750
    results['go_back_url'] = go_back_url
751
    working_issue = gitlab_get_issue(project=gitlab_id, issue=gitlab_iid)
752
    results['issue'] = working_issue.attributes
753
754
    # Get the notes list, and then for every note in the list, grab that
    # note's attributes, which includes the body text, etc.
755
    results['notes'] = []
756
    notes_list = gitlab_get_notes_list(project=gitlab_id, issue=gitlab_iid)
757
758
759
760
    for note in notes_list:
        note_dict = note.attributes
        results['notes'].append(note_dict)
    results['notes'].reverse()
761
    # Generate notes link.
ViolanteCodes's avatar
ViolanteCodes committed
762
763
764
    new_note_link = reverse('create-note', args=[
        user_identifier, project_slug, gitlab_iid
        ])
765
    results['new_note_link'] = new_note_link
766
767
    return render(request, 'anonticket/issue_detail.html', {'results': results})

768
769
770
771
@validate_user
def issue_search_view(request, user_identifier):
    """Allows a user to select a project and search Gitlab for issues matching
    a search string."""
772
    results = {}
773
    if 'search_terms' in request.GET:
774
775
        form = Anonymous_Ticket_Project_Search_Form(request.GET)
        if form.is_valid():
776
            results = form.call_project_and_issue(user_identifier)
777
            results['user_identifier'] = user_identifier
778
779
    else:
        form = Anonymous_Ticket_Project_Search_Form
780
        results['user_identifier'] = user_identifier
781
    return render(request, 'anonticket/issue_search.html', {'form': form, 'results': results})
782

783
784
785
786
# ---------------------------NOTE_VIEWS---------------------------------
# Views related to creating/looking up notes.
# ----------------------------------------------------------------------

ViolanteCodes's avatar
ViolanteCodes committed
787
@method_decorator(validate_user, name='dispatch')
ViolanteCodes's avatar
ViolanteCodes committed
788
789
790
@method_decorator(custom_ratelimit_ip(), name='post')
@method_decorator(custom_ratelimit_post(), name='post')
class NoteCreateView(PassUserIdentifierMixin, CreateView):
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
    """View to create a note given a user_identifier."""
    model=Note
    fields = ['body']
    template_name_suffix = '_create_form'

    def form_valid(self, form):
        """Populate default Issue object attributes from URL path."""
        linked_project = get_object_or_404(Project, slug=self.kwargs['project'])
        form.instance.linked_project = linked_project
        try:
            linked_user = UserIdentifier.objects.get(user_identifier=self.kwargs['user_identifier'])
            form.instance.linked_user = linked_user
            # If lookup fails, create the user.
        except: 
            new_user = UserIdentifier(user_identifier=self.kwargs['user_identifier'])
            new_user.save()
            form.instance.linked_user = new_user
808
809
        issue_iid = self.kwargs['issue_iid']
        form.instance.issue_iid = issue_iid
810
811
812
813
814
815
816
        return super(NoteCreateView, self).form_valid(form)

    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        working_object = self.object
        user_identifier_to_pass = working_object.linked_user.user_identifier
        working_url = reverse('issue-created', args=[user_identifier_to_pass])
ViolanteCodes's avatar
ViolanteCodes committed
817
818
        return working_url

819
820
821
822
823
class PendingNoteDetailView(PassUserIdentifierMixin, DetailView):
    """View For Pending Notes that have not been  mod approved."""
    model = Note
    template_name = 'anonticket/note_pending.html'
    
824
825
# ----------------------MODERATOR_VIEWS---------------------------------
# Views related to moderators. Should all have decorator 
826
827
828
# @staff_member_required, which forces staff status and allows access
# to the login form from the admin panel, and the @is_moderator 
# decorator, which tests that user is a member of the group Moderator.
829
830
# ----------------------------------------------------------------------

831
832
#Functions that check group status:

833
def is_moderator(user):
ViolanteCodes's avatar
ViolanteCodes committed
834
    """Check if the user is either in the Moderators group or a superuser."""
835
836
837
838
839
840
    if user.groups.filter(name="Moderators").exists() == True:
        return True
    elif user.is_superuser:
        return True
    else:
        return False
841
842

def is_account_approver(user):
ViolanteCodes's avatar
ViolanteCodes committed
843
    """Check if the user is in the Account Approvers group or superuser."""
844
845
846
847
848
849
    if user.groups.filter(name="Account Approvers").exists() == True:
        return True
    elif user.is_superuser:
        return True
    else:
        return False
850
851

def is_mod_or_approver(user):
852
853
    """A function to check that a logged-in user is either a moderator,
    an account approver, or a super_user."""
854
855
856
    if is_moderator(user) == True:
        return True
    elif is_account_approver(user) == True:
857
858
859
        return True
    else:
        return False
860

861
862
863
864
865
# Set the login_redirect_url variable. Staff should not be able to
# access NoteUpdateView or IssueUpdateView without going through
# /moderator first, so all moderator views can redirect to the 
# same place in the event the staff member is not logged in.
 
866
867
868
login_redirect_url = "/tor_admin/login/?next=/moderator/"

@user_passes_test(
869
    is_mod_or_approver, login_url=login_redirect_url)
ViolanteCodes's avatar
ViolanteCodes committed
870
@staff_member_required
871
def moderator_view(request):
872
    """View that allows moderators and account approvers to approve pending items."""
873
874
875
    from anonticket.forms import (
        PendingNoteFormSet, 
        PendingIssueFormSet, 
876
877
        PendingGitlabAccountRequestFormSet,
        )
878
    user = request.user
879
    messages = {}
ViolanteCodes's avatar
ViolanteCodes committed
880
    if request.method == 'POST':
881
        # if POST, verify that the user is in the moderators group.
882
        if is_moderator(user) == True:
883
884
885
886
            note_formset = PendingNoteFormSet(
                prefix="note_formset", data=request.POST)
            issue_formset = PendingIssueFormSet(
                prefix="issue_formset", data=request.POST)
887
            if issue_formset.is_valid():
888
                issue_formset.save()
889
            if note_formset.is_valid():
890
891
892
893
                note_formset.save()
            else:
                print(issue_formset.errors)
                print(note_formset.errors)
894
        # or that the user is in the account approvers group.
895
896
897
898
899
        if is_account_approver(user) == True:
            gitlab_formset = PendingGitlabAccountRequestFormSet(
                prefix="gitlab_formset", data=request.POST)
            if gitlab_formset.is_valid():
                gitlab_formset.save()
900
            else:
901
                print(gitlab_formset.errors)
902
        # Regardless of result, return redirect to 'moderator' 
903
        return redirect('/moderator/')
ViolanteCodes's avatar
ViolanteCodes committed
904
    else:
905
        # if request method is not POST, pull formsets and render them in the template.
906
907
908
909
        if is_moderator(user) == True:
            note_formset = PendingNoteFormSet(prefix="note_formset")
            issue_formset = PendingIssueFormSet(prefix="issue_formset")
        else:
910
911
            note_formset = {}
            issue_formset = {}
912
913
914
915
916
            messages['note_message'] = """You do not have permission to 
            view pending notes at this time."""
            messages['issue_message'] = """You do not have permission to 
            view pending issues at this time."""
        if is_account_approver(user) == True:
917
            gitlab_formset = PendingGitlabAccountRequestFormSet(
918
919
                prefix="gitlab_formset")
        else:
920
921
            gitlab_formset = {}
            messages['gitlab_message'] = """You do not 
922
923
924
925
            have permission to view pending Gitlab account requests at this time."""
    return render(request, "anonticket/moderator.html", {
        "note_formset": note_formset, 
        "issue_formset":issue_formset,
926
927
928
        "gitlab_formset": gitlab_formset, 
        "messages": messages
        })
929

930
@method_decorator(user_passes_test(
931
    is_moderator, login_url=login_redirect_url), name='dispatch')
932
933
934
935
@method_decorator(staff_member_required, name='dispatch')
class ModeratorNoteUpdateView(UpdateView):
    """View that allows a moderator to update a Note."""
    model = Note
936
    fields= ['body', 'mod_comment', 'reviewer_status']
937
938
    template_name_suffix = '_update_form'

939
940
941
942
943
    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        url = reverse('moderator')
        return url

944
@method_decorator(user_passes_test(
945
    is_moderator, login_url=login_redirect_url), name='dispatch')
946
947
948
949
@method_decorator(staff_member_required, name='dispatch')
class ModeratorIssueUpdateView(UpdateView):
    """View that allows a moderator to update an issue."""
    model = Issue
950
    fields= ['linked_project', 'description', 'mod_comment', 'reviewer_status']
951
952
    template_name_suffix = '_update_form'

953
954
955
956
957
958
959
960
    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        url = reverse('moderator')
        return url

@method_decorator(user_passes_test(
    is_account_approver, login_url=login_redirect_url), name='dispatch')
@method_decorator(staff_member_required, name='dispatch')
961
class ModeratorGitlabAccountRequestUpdateView(UpdateView):
962
963
964
965
966
    """View that allows a moderator to update an issue."""
    model = GitlabAccountRequest
    fields= ['username', 'email', 'reason', 'mod_comment','reviewer_status']
    template_name_suffix = '_update_form'

967
968
969
970
    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        url = reverse('moderator')
        return url