sysadmin.py 24.5 KB
Newer Older
Carson Gee committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
"""
This module creates a sysadmin dashboard for managing and viewing
courses.
"""
import csv
import json
import logging
import os
import subprocess
import StringIO

from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
17
from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
Carson Gee committed
18 19 20 21
from django.db import IntegrityError
from django.http import HttpResponse, Http404
from django.utils.decorators import method_decorator
from django.utils.html import escape
22
from django.utils import timezone
Carson Gee committed
23 24 25 26
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
from django.views.generic.base import TemplateView
from django.views.decorators.http import condition
27
from django.views.decorators.csrf import ensure_csrf_cookie
Carson Gee committed
28 29
from edxmako.shortcuts import render_to_response
import mongoengine
30
from path import Path as path
Carson Gee committed
31 32

from courseware.courses import get_course_by_id
33 34
import dashboard.git_import as git_import
from dashboard.git_import import GitImportError
35
from student.roles import CourseStaffRole, CourseInstructorRole
Carson Gee committed
36 37 38 39 40 41
from dashboard.models import CourseImportLog
from external_auth.models import ExternalAuthMap
from external_auth.views import generate_password
from student.models import CourseEnrollment, UserProfile, Registration
import track.views
from xmodule.modulestore.django import modulestore
42
from opaque_keys.edx.locations import SlashSeparatedCourseKey
Carson Gee committed
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58


log = logging.getLogger(__name__)


class SysadminDashboardView(TemplateView):
    """Base class for sysadmin dashboard views with common methods"""

    template_name = 'sysadmin_dashboard.html'

    def __init__(self, **kwargs):
        """
        Initialize base sysadmin dashboard class with modulestore,
        modulestore_type and return msg
        """

59
        self.def_ms = modulestore()
Carson Gee committed
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
        self.msg = u''
        self.datatable = []
        super(SysadminDashboardView, self).__init__(**kwargs)

    @method_decorator(ensure_csrf_cookie)
    @method_decorator(login_required)
    @method_decorator(cache_control(no_cache=True, no_store=True,
                                    must_revalidate=True))
    @method_decorator(condition(etag_func=None))
    def dispatch(self, *args, **kwargs):
        return super(SysadminDashboardView, self).dispatch(*args, **kwargs)

    def get_courses(self):
        """ Get an iterable list of courses."""

75
        return self.def_ms.get_courses()
Carson Gee committed
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103

    def return_csv(self, filename, header, data):
        """
        Convenient function for handling the http response of a csv.
        data should be iterable and is used to stream object over http
        """

        csv_file = StringIO.StringIO()
        writer = csv.writer(csv_file, dialect='excel', quotechar='"',
                            quoting=csv.QUOTE_ALL)

        writer.writerow(header)

        # Setup streaming of the data
        def read_and_flush():
            """Read and clear buffer for optimization"""
            csv_file.seek(0)
            csv_data = csv_file.read()
            csv_file.seek(0)
            csv_file.truncate()
            return csv_data

        def csv_data():
            """Generator for handling potentially large CSVs"""
            for row in data:
                writer.writerow(row)
            csv_data = read_and_flush()
            yield csv_data
104
        response = HttpResponse(csv_data(), content_type='text/csv')
Carson Gee committed
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130
        response['Content-Disposition'] = 'attachment; filename={0}'.format(
            filename)
        return response


class Users(SysadminDashboardView):
    """
    The status view provides Web based user management, a listing of
    courses loaded, and user statistics
    """

    def fix_external_auth_map_passwords(self):
        """
        This corrects any passwords that have drifted from eamap to
        internal django auth.  Needs to be removed when fixed in external_auth
        """

        msg = ''
        for eamap in ExternalAuthMap.objects.all():
            euser = eamap.user
            epass = eamap.internal_password
            if euser is None:
                continue
            try:
                testuser = authenticate(username=euser.username, password=epass)
            except (TypeError, PermissionDenied, AttributeError), err:
131 132 133 134 135 136
                # Translators: This message means that the user could not be authenticated (that is, we could
                # not log them in for some reason - maybe they don't have permission, or their password was wrong)
                msg += _('Failed in authenticating {username}, error {error}\n').format(
                    username=euser,
                    error=err
                )
Carson Gee committed
137 138
                continue
            if testuser is None:
139 140 141 142 143
                # Translators: This message means that the user could not be authenticated (that is, we could
                # not log them in for some reason - maybe they don't have permission, or their password was wrong)
                msg += _('Failed in authenticating {username}\n').format(username=euser)
                # Translators: this means that the password has been corrected (sometimes the database needs to be resynchronized)
                # Translate this as meaning "the password was fixed" or "the password was corrected".
Carson Gee committed
144 145 146 147 148
                msg += _('fixed password')
                euser.set_password(epass)
                euser.save()
                continue
        if not msg:
149
            # Translators: this means everything happened successfully, yay!
Carson Gee committed
150 151 152 153 154 155 156 157 158 159 160 161 162 163
            msg = _('All ok!')
        return msg

    def create_user(self, uname, name, password=None):
        """ Creates a user (both SSL and regular)"""

        if not uname:
            return _('Must provide username')
        if not name:
            return _('Must provide full name')

        email_domain = getattr(settings, 'SSL_AUTH_EMAIL_DOMAIN', 'MIT.EDU')

        msg = u''
164
        if settings.FEATURES['AUTH_USE_CERTIFICATES']:
David Baumgold committed
165
            if '@' not in uname:
Carson Gee committed
166 167 168 169
                email = '{0}@{1}'.format(uname, email_domain)
            else:
                email = uname
            if not email.endswith('@{0}'.format(email_domain)):
170 171
                # Translators: Domain is an email domain, such as "@gmail.com"
                msg += _('Email address must end in {domain}').format(domain="@{0}".format(email_domain))
Carson Gee committed
172 173 174 175
                return msg
            mit_domain = 'ssl:MIT'
            if ExternalAuthMap.objects.filter(external_id=email,
                                              external_domain=mit_domain):
176 177 178 179
                msg += _('Failed - email {email_addr} already exists as {external_id}').format(
                    email_addr=email,
                    external_id="external_id"
                )
Carson Gee committed
180 181 182 183 184 185 186 187
                return msg
            new_password = generate_password()
        else:
            if not password:
                return _('Password must be supplied if not using certificates')

            email = uname

David Baumgold committed
188
            if '@' not in email:
Carson Gee committed
189 190 191 192 193 194 195 196 197
                msg += _('email address required (not username)')
                return msg
            new_password = password

        user = User(username=uname, email=email, is_active=True)
        user.set_password(new_password)
        try:
            user.save()
        except IntegrityError:
198 199 200 201
            msg += _('Oops, failed to create user {user}, {error}').format(
                user=user,
                error="IntegrityError"
            )
Carson Gee committed
202 203 204 205 206 207 208 209 210
            return msg

        reg = Registration()
        reg.register(user)

        profile = UserProfile(user=user)
        profile.name = name
        profile.save()

211
        if settings.FEATURES['AUTH_USE_CERTIFICATES']:
Carson Gee committed
212 213 214 215 216 217 218 219 220 221 222 223
            credential_string = getattr(settings, 'SSL_AUTH_DN_FORMAT_STRING',
                                        '/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}')
            credentials = credential_string.format(name, email)
            eamap = ExternalAuthMap(
                external_id=email,
                external_email=email,
                external_domain=mit_domain,
                external_name=name,
                internal_password=new_password,
                external_credentials=json.dumps(credentials),
            )
            eamap.user = user
224
            eamap.dtsignup = timezone.now()
Carson Gee committed
225 226
            eamap.save()

227
        msg += _('User {user} created successfully!').format(user=user)
Carson Gee committed
228 229 230 231 232 233 234 235 236 237 238
        return msg

    def delete_user(self, uname):
        """Deletes a user from django auth"""

        if not uname:
            return _('Must provide username')
        if '@' in uname:
            try:
                user = User.objects.get(email=uname)
            except User.DoesNotExist, err:
239
                msg = _('Cannot find user with email address {email_addr}').format(email_addr=uname)
Carson Gee committed
240 241 242 243 244
                return msg
        else:
            try:
                user = User.objects.get(username=uname)
            except User.DoesNotExist, err:
245 246 247 248
                msg = _('Cannot find user with username {username} - {error}').format(
                    username=uname,
                    error=str(err)
                )
Carson Gee committed
249 250
                return msg
        user.delete()
251
        return _('Deleted user {username}').format(username=uname)
Carson Gee committed
252 253 254 255 256 257 258 259 260 261 262 263

    def make_common_context(self):
        """Returns the datatable used for this view"""

        self.datatable = {}

        self.datatable = dict(header=[_('Statistic'), _('Value')],
                              title=_('Site statistics'))
        self.datatable['data'] = [[_('Total number of users'),
                                   User.objects.all().count()]]

        self.msg += u'<h2>{0}</h2>'.format(
264 265
            _('Courses loaded in the modulestore')
        )
Carson Gee committed
266
        self.msg += u'<ol>'
267
        for course in self.get_courses():
Carson Gee committed
268
            self.msg += u'<li>{0} ({1})</li>'.format(
269
                escape(course.id.to_deprecated_string()), course.location.to_deprecated_string())
Carson Gee committed
270 271 272 273 274 275 276 277 278 279 280 281 282
        self.msg += u'</ol>'

    def get(self, request):

        if not request.user.is_staff:
            raise Http404
        self.make_common_context()

        context = {
            'datatable': self.datatable,
            'msg': self.msg,
            'djangopid': os.getpid(),
            'modeflag': {'users': 'active-section'},
283
            'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
Carson Gee committed
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326
        }
        return render_to_response(self.template_name, context)

    def post(self, request):
        """Handle various actions available on page"""

        if not request.user.is_staff:
            raise Http404

        self.make_common_context()

        action = request.POST.get('action', '')
        track.views.server_track(request, action, {}, page='user_sysdashboard')

        if action == 'download_users':
            header = [_('username'), _('email'), ]
            data = ([u.username, u.email] for u in
                    (User.objects.all().iterator()))
            return self.return_csv('users_{0}.csv'.format(
                request.META['SERVER_NAME']), header, data)
        elif action == 'repair_eamap':
            self.msg = u'<h4>{0}</h4><pre>{1}</pre>{2}'.format(
                _('Repair Results'),
                self.fix_external_auth_map_passwords(),
                self.msg)
            self.datatable = {}
        elif action == 'create_user':
            uname = request.POST.get('student_uname', '').strip()
            name = request.POST.get('student_fullname', '').strip()
            password = request.POST.get('student_password', '').strip()
            self.msg = u'<h4>{0}</h4><p>{1}</p><hr />{2}'.format(
                _('Create User Results'),
                self.create_user(uname, name, password), self.msg)
        elif action == 'del_user':
            uname = request.POST.get('student_uname', '').strip()
            self.msg = u'<h4>{0}</h4><p>{1}</p><hr />{2}'.format(
                _('Delete User Results'), self.delete_user(uname), self.msg)

        context = {
            'datatable': self.datatable,
            'msg': self.msg,
            'djangopid': os.getpid(),
            'modeflag': {'users': 'active-section'},
327
            'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
Carson Gee committed
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343
        }
        return render_to_response(self.template_name, context)


class Courses(SysadminDashboardView):
    """
    This manages adding/updating courses from git, deleting courses, and
    provides course listing information.
    """

    def git_info_for_course(self, cdir):
        """This pulls out some git info like the last commit"""

        cmd = ''
        gdir = settings.DATA_DIR / cdir
        info = ['', '', '']
344 345 346

        # Try the data dir, then try to find it in the git import dir
        if not gdir.exists():
347 348
            git_repo_dir = getattr(settings, 'GIT_REPO_DIR', git_import.DEFAULT_GIT_REPO_DIR)
            gdir = path(git_repo_dir / cdir)
349 350
            if not gdir.exists():
                return info
Carson Gee committed
351 352 353 354 355 356 357 358 359 360 361 362 363

        cmd = ['git', 'log', '-1',
               '--format=format:{ "commit": "%H", "author": "%an %ae", "date": "%ad"}', ]
        try:
            output_json = json.loads(subprocess.check_output(cmd, cwd=gdir))
            info = [output_json['commit'],
                    output_json['date'],
                    output_json['author'], ]
        except (ValueError, subprocess.CalledProcessError):
            pass

        return info

364
    def get_course_from_git(self, gitloc, branch):
Carson Gee committed
365 366 367 368 369 370 371
        """This downloads and runs the checks for importing a course in git"""

        if not (gitloc.endswith('.git') or gitloc.startswith('http:') or
                gitloc.startswith('https:') or gitloc.startswith('git:')):
            return _("The git repo location should end with '.git', "
                     "and be a valid url")

372
        return self.import_mongo_course(gitloc, branch)
Carson Gee committed
373

374
    def import_mongo_course(self, gitloc, branch):
Carson Gee committed
375 376 377 378 379 380 381
        """
        Imports course using management command and captures logging output
        at debug level for display in template
        """

        msg = u''

382
        log.debug('Adding course using git repo %s', gitloc)
Carson Gee committed
383 384 385 386 387 388 389

        # Grab logging output for debugging imports
        output = StringIO.StringIO()
        import_log_handler = logging.StreamHandler(output)
        import_log_handler.setLevel(logging.DEBUG)

        logger_names = ['xmodule.modulestore.xml_importer',
390 391 392
                        'dashboard.git_import',
                        'xmodule.modulestore.xml',
                        'xmodule.seq_module', ]
Carson Gee committed
393 394 395 396 397 398 399 400
        loggers = []

        for logger_name in logger_names:
            logger = logging.getLogger(logger_name)
            logger.setLevel(logging.DEBUG)
            logger.addHandler(import_log_handler)
            loggers.append(logger)

401 402
        error_msg = ''
        try:
403
            git_import.add_repo(gitloc, None, branch)
404 405
        except GitImportError as ex:
            error_msg = str(ex)
Carson Gee committed
406 407 408 409
        ret = output.getvalue()

        # Remove handler hijacks
        for logger in loggers:
410
            logger.setLevel(logging.NOTSET)
Carson Gee committed
411 412
            logger.removeHandler(import_log_handler)

413 414 415 416 417 418 419 420
        if error_msg:
            msg_header = error_msg
            color = 'red'
        else:
            msg_header = _('Added Course')
            color = 'blue'

        msg = u"<h4 style='color:{0}'>{1}</h4>".format(color, msg_header)
421
        msg += u"<pre>{0}</pre>".format(escape(ret))
Carson Gee committed
422 423 424 425 426 427 428
        return msg

    def make_datatable(self):
        """Creates course information datatable"""

        data = []

429
        for course in self.get_courses():
430
            gdir = course.id.course
431
            data.append([course.display_name, course.id.to_deprecated_string()]
Carson Gee committed
432 433
                        + self.git_info_for_course(gdir))

434 435 436 437 438
        return dict(header=[_('Course Name'),
                            _('Directory/ID'),
                            # Translators: "Git Commit" is a computer command; see http://gitref.org/basic/#commit
                            _('Git Commit'),
                            _('Last Change'),
Carson Gee committed
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
                            _('Last Editor')],
                    title=_('Information about all courses'),
                    data=data)

    def get(self, request):
        """Displays forms and course information"""

        if not request.user.is_staff:
            raise Http404

        context = {
            'datatable': self.make_datatable(),
            'msg': self.msg,
            'djangopid': os.getpid(),
            'modeflag': {'courses': 'active-section'},
454
            'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
Carson Gee committed
455 456 457 458 459 460 461 462 463 464 465 466 467
        }
        return render_to_response(self.template_name, context)

    def post(self, request):
        """Handle all actions from courses view"""

        if not request.user.is_staff:
            raise Http404

        action = request.POST.get('action', '')
        track.views.server_track(request, action, {},
                                 page='courses_sysdashboard')

468
        courses = {course.id: course for course in self.get_courses()}
Carson Gee committed
469 470
        if action == 'add_course':
            gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '')
471
            branch = request.POST.get('repo_branch', '').strip().replace(' ', '').replace(';', '')
472
            self.msg += self.get_course_from_git(gitloc, branch)
Carson Gee committed
473 474 475

        elif action == 'del_course':
            course_id = request.POST.get('course_id', '').strip()
476
            course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
Carson Gee committed
477
            course_found = False
478
            if course_key in courses:
Carson Gee committed
479
                course_found = True
480
                course = courses[course_key]
Carson Gee committed
481 482
            else:
                try:
483
                    course = get_course_by_id(course_key)
Carson Gee committed
484 485
                    course_found = True
                except Exception, err:   # pylint: disable=broad-except
486 487 488 489 490 491
                    self.msg += _(
                        'Error - cannot get course with ID {0}<br/><pre>{1}</pre>'
                    ).format(
                        course_key,
                        escape(str(err))
                    )
Carson Gee committed
492

493
            if course_found:
Carson Gee committed
494
                # delete course that is stored with mongodb backend
495
                self.def_ms.delete_course(course.id, request.user.id)
Carson Gee committed
496 497 498
                # don't delete user permission groups, though
                self.msg += \
                    u"<font color='red'>{0} {1} = {2} ({3})</font>".format(
Calen Pennington committed
499
                        _('Deleted'), course.location.to_deprecated_string(), course.id.to_deprecated_string(), course.display_name)
Carson Gee committed
500 501

        context = {
502
            'datatable': self.make_datatable(),
Carson Gee committed
503 504 505
            'msg': self.msg,
            'djangopid': os.getpid(),
            'modeflag': {'courses': 'active-section'},
506
            'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
Carson Gee committed
507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523
        }
        return render_to_response(self.template_name, context)


class Staffing(SysadminDashboardView):
    """
    The status view provides a view of staffing and enrollment in
    courses that include an option to download the data as a csv.
    """

    def get(self, request):
        """Displays course Enrollment and staffing course statistics"""

        if not request.user.is_staff:
            raise Http404
        data = []

524
        for course in self.get_courses():
Carson Gee committed
525 526 527
            datum = [course.display_name, course.id]
            datum += [CourseEnrollment.objects.filter(
                course_id=course.id).count()]
528
            datum += [CourseStaffRole(course.id).users_with_role().count()]
Carson Gee committed
529
            datum += [','.join([x.username for x in CourseInstructorRole(
530
                course.id).users_with_role()])]
Carson Gee committed
531 532 533 534 535 536 537 538 539 540 541 542
            data.append(datum)

        datatable = dict(header=[_('Course Name'), _('course_id'),
                                 _('# enrolled'), _('# staff'),
                                 _('instructors')],
                         title=_('Enrollment information for all courses'),
                         data=data)
        context = {
            'datatable': datatable,
            'msg': self.msg,
            'djangopid': os.getpid(),
            'modeflag': {'staffing': 'active-section'},
543
            'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
Carson Gee committed
544 545 546 547 548 549 550 551 552 553 554 555 556 557
        }
        return render_to_response(self.template_name, context)

    def post(self, request):
        """Handle all actions from staffing and enrollment view"""

        action = request.POST.get('action', '')
        track.views.server_track(request, action, {},
                                 page='staffing_sysdashboard')

        if action == 'get_staff_csv':
            data = []
            roles = [CourseInstructorRole, CourseStaffRole, ]

558
            for course in self.get_courses():
Carson Gee committed
559
                for role in roles:
560
                    for user in role(course.id).users_with_role():
Carson Gee committed
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586
                        datum = [course.id, role, user.username, user.email,
                                 user.profile.name]
                        data.append(datum)
            header = [_('course_id'),
                      _('role'), _('username'),
                      _('email'), _('full_name'), ]
            return self.return_csv('staff_{0}.csv'.format(
                request.META['SERVER_NAME']), header, data)

        return self.get(request)


class GitLogs(TemplateView):
    """
    This provides a view into the import of courses from git repositories.
    It is convenient for allowing course teams to see what may be wrong with
    their xml
    """

    template_name = 'sysadmin_dashboard_gitlogs.html'

    @method_decorator(login_required)
    def get(self, request, *args, **kwargs):
        """Shows logs of imports that happened as a result of a git import"""

        course_id = kwargs.get('course_id')
587 588
        if course_id:
            course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
Carson Gee committed
589

590 591
        page_size = 10

Carson Gee committed
592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615
        # Set mongodb defaults even if it isn't defined in settings
        mongo_db = {
            'host': 'localhost',
            'user': '',
            'password': '',
            'db': 'xlog',
        }

        # Allow overrides
        if hasattr(settings, 'MONGODB_LOG'):
            for config_item in ['host', 'user', 'password', 'db', ]:
                mongo_db[config_item] = settings.MONGODB_LOG.get(
                    config_item, mongo_db[config_item])

        mongouri = 'mongodb://{user}:{password}@{host}/{db}'.format(**mongo_db)

        error_msg = ''

        try:
            if mongo_db['user'] and mongo_db['password']:
                mdb = mongoengine.connect(mongo_db['db'], host=mongouri)
            else:
                mdb = mongoengine.connect(mongo_db['db'], host=mongo_db['host'])
        except mongoengine.connection.ConnectionError:
616
            log.exception('Unable to connect to mongodb to save log, '
617
                          'please check MONGODB_LOG settings.')
Carson Gee committed
618 619 620 621 622

        if course_id is None:
            # Require staff if not going to specific course
            if not request.user.is_staff:
                raise Http404
623
            cilset = CourseImportLog.objects.order_by('-created')
Carson Gee committed
624 625 626
        else:
            try:
                course = get_course_by_id(course_id)
627
            except Exception:
628
                log.info('Cannot find course %s', course_id)
629
                raise Http404
Carson Gee committed
630 631 632

            # Allow only course team, instructors, and staff
            if not (request.user.is_staff or
633 634
                    CourseInstructorRole(course.id).has_user(request.user) or
                    CourseStaffRole(course.id).has_user(request.user)):
Carson Gee committed
635
                raise Http404
636
            log.debug('course_id=%s', course_id)
637 638 639
            cilset = CourseImportLog.objects.filter(
                course_id=course_id
            ).order_by('-created')
640
            log.debug('cilset length=%s', len(cilset))
641 642 643 644 645 646 647 648

        # Paginate the query set
        paginator = Paginator(cilset, page_size)
        try:
            logs = paginator.page(request.GET.get('page'))
        except PageNotAnInteger:
            logs = paginator.page(1)
        except EmptyPage:
649 650 651 652
            # If the page is too high or low
            given_page = int(request.GET.get('page'))
            page = min(max(1, given_page), paginator.num_pages)
            logs = paginator.page(page)
653

Carson Gee committed
654
        mdb.disconnect()
655 656 657 658 659 660
        context = {
            'logs': logs,
            'course_id': course_id.to_deprecated_string() if course_id else None,
            'error_msg': error_msg,
            'page_size': page_size
        }
Carson Gee committed
661 662

        return render_to_response(self.template_name, context)