Browse Source

Initial commit

master
Vitor Freitas 3 years ago
parent
commit
ff0d1ef31c
No known key found for this signature in database GPG Key ID: 11D07CF62D8BE9DE
  1. 2
      .gitignore
  2. 0
      django_school/classroom/__init__.py
  3. 5
      django_school/classroom/apps.py
  4. 32
      django_school/classroom/decorators.py
  5. 75
      django_school/classroom/forms.py
  6. 144
      django_school/classroom/migrations/0001_initial.py
  7. 23
      django_school/classroom/migrations/0002_create_initial_subjects.py
  8. 0
      django_school/classroom/migrations/__init__.py
  9. 76
      django_school/classroom/models.py
  10. 18
      django_school/classroom/templates/classroom/home.html
  11. 44
      django_school/classroom/templates/classroom/students/quiz_list.html
  12. 13
      django_school/classroom/templates/classroom/students/take_quiz_form.html
  13. 40
      django_school/classroom/templates/classroom/students/taken_quiz_list.html
  14. 21
      django_school/classroom/templates/classroom/teachers/question_add_form.html
  15. 64
      django_school/classroom/templates/classroom/teachers/question_change_form.html
  16. 23
      django_school/classroom/templates/classroom/teachers/quiz_add_form.html
  17. 56
      django_school/classroom/templates/classroom/teachers/quiz_change_form.html
  18. 41
      django_school/classroom/templates/classroom/teachers/quiz_change_list.html
  19. 42
      django_school/classroom/templates/classroom/teachers/quiz_results.html
  20. 3
      django_school/classroom/tests.py
  21. 24
      django_school/classroom/urls.py
  22. 0
      django_school/classroom/views/__init__.py
  23. 15
      django_school/classroom/views/classroom.py
  24. 98
      django_school/classroom/views/students.py
  25. 172
      django_school/classroom/views/teachers.py
  26. 0
      django_school/django_school/__init__.py
  27. 142
      django_school/django_school/settings.py
  28. 13
      django_school/django_school/urls.py
  29. 16
      django_school/django_school/wsgi.py
  30. 15
      django_school/manage.py
  31. 32
      django_school/static/css/app.css
  32. 3
      django_school/static/css/students.css
  33. 3
      django_school/static/css/teachers.css
  34. 21
      django_school/static/vendor/fontello-2f186091/LICENSE.txt
  35. 75
      django_school/static/vendor/fontello-2f186091/README.txt
  36. 28
      django_school/static/vendor/fontello-2f186091/config.json
  37. 85
      django_school/static/vendor/fontello-2f186091/css/animation.css
  38. 4
      django_school/static/vendor/fontello-2f186091/css/fontello-codes.css
  39. 57
      django_school/static/vendor/fontello-2f186091/css/fontello-embedded.css
  40. 4
      django_school/static/vendor/fontello-2f186091/css/fontello-ie7-codes.css
  41. 15
      django_school/static/vendor/fontello-2f186091/css/fontello-ie7.css
  42. 60
      django_school/static/vendor/fontello-2f186091/css/fontello.css
  43. 311
      django_school/static/vendor/fontello-2f186091/demo.html
  44. BIN
      django_school/static/vendor/fontello-2f186091/font/fontello.eot
  45. 16
      django_school/static/vendor/fontello-2f186091/font/fontello.svg
  46. BIN
      django_school/static/vendor/fontello-2f186091/font/fontello.ttf
  47. BIN
      django_school/static/vendor/fontello-2f186091/font/fontello.woff
  48. BIN
      django_school/static/vendor/fontello-2f186091/font/fontello.woff2
  49. 25
      django_school/templates/404.html
  50. 26
      django_school/templates/500.html
  51. 78
      django_school/templates/base.html
  52. 17
      django_school/templates/registration/login.html
  53. 8
      django_school/templates/registration/signup.html
  54. 17
      django_school/templates/registration/signup_form.html
  55. 3
      requirements.txt

2
.gitignore

@ -99,3 +99,5 @@ ENV/
# mypy
.mypy_cache/
*.sqlite3

0
django_school/classroom/__init__.py

5
django_school/classroom/apps.py

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ClassroomConfig(AppConfig):
name = 'classroom'

32
django_school/classroom/decorators.py

@ -0,0 +1,32 @@
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import user_passes_test
def student_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url='login'):
'''
Decorator for views that checks that the logged in user is a student,
redirects to the log-in page if necessary.
'''
actual_decorator = user_passes_test(
lambda u: u.is_active and u.is_student,
login_url=login_url,
redirect_field_name=redirect_field_name
)
if function:
return actual_decorator(function)
return actual_decorator
def teacher_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url='login'):
'''
Decorator for views that checks that the logged in user is a teacher,
redirects to the log-in page if necessary.
'''
actual_decorator = user_passes_test(
lambda u: u.is_active and u.is_teacher,
login_url=login_url,
redirect_field_name=redirect_field_name
)
if function:
return actual_decorator(function)
return actual_decorator

75
django_school/classroom/forms.py

@ -0,0 +1,75 @@
from django import forms
from django.db import transaction
from django.contrib.auth.forms import UserCreationForm
from django.forms.utils import ValidationError
from classroom.models import Answer, User, Student, Subject, Question, StudentAnswer
class TeacherSignUpForm(UserCreationForm):
class Meta(UserCreationForm.Meta):
model = User
def save(self, commit=True):
user = super().save(commit=False)
user.is_teacher = True
if commit:
user.save()
return user
class StudentSignUpForm(UserCreationForm):
interests = forms.ModelMultipleChoiceField(
queryset=Subject.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False
)
class Meta(UserCreationForm.Meta):
model = User
@transaction.atomic
def save(self):
user = super().save(commit=False)
user.is_student = True
user.save()
student = Student.objects.create(user=user)
student.interests.add(*self.cleaned_data.get('interests'))
return user
class QuestionForm(forms.ModelForm):
class Meta:
model = Question
fields = ('text', )
class BaseAnswerInlineFormSet(forms.BaseInlineFormSet):
def clean(self):
super().clean()
has_one_correct_answer = False
for form in self.forms:
if not form.cleaned_data.get('DELETE', False):
if form.cleaned_data.get('is_correct', False):
has_one_correct_answer = True
break
if not has_one_correct_answer:
raise ValidationError('Mark at least one answer as correct.', code='no_correct_answer')
class TakeQuizForm(forms.ModelForm):
answer = forms.ModelChoiceField(
queryset=Answer.objects.none(),
widget=forms.RadioSelect(),
required=True,
empty_label=None)
class Meta:
model = StudentAnswer
fields = ('answer', )
def __init__(self, *args, **kwargs):
question = kwargs.pop('question')
super().__init__(*args, **kwargs)
self.fields['answer'].queryset = question.answers.order_by('text')

144
django_school/classroom/migrations/0001_initial.py

@ -0,0 +1,144 @@
# Generated by Django 2.0.1 on 2018-01-18 18:07
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0009_alter_user_last_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('is_student', models.BooleanField(default=False)),
('is_teacher', models.BooleanField(default=False)),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Answer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(max_length=255, verbose_name='Answer')),
('is_correct', models.BooleanField(default=False, verbose_name='Correct answer')),
],
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(max_length=255, verbose_name='Question')),
],
),
migrations.CreateModel(
name='Quiz',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='StudentAnswer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='classroom.Answer')),
],
),
migrations.CreateModel(
name='Subject',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('color', models.CharField(default='#007bff', max_length=7)),
],
),
migrations.CreateModel(
name='TakenQuiz',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.FloatField()),
('date', models.DateTimeField(auto_now_add=True)),
('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='taken_quizzes', to='classroom.Quiz')),
],
),
migrations.CreateModel(
name='Student',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('interests', models.ManyToManyField(related_name='interested_students', to='classroom.Subject')),
],
),
migrations.AddField(
model_name='quiz',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='quiz',
name='subject',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='classroom.Subject'),
),
migrations.AddField(
model_name='question',
name='quiz',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='classroom.Quiz'),
),
migrations.AddField(
model_name='answer',
name='question',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='classroom.Question'),
),
migrations.AddField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
migrations.AddField(
model_name='takenquiz',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='taken_quizzes', to='classroom.Student'),
),
migrations.AddField(
model_name='studentanswer',
name='student',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz_answers', to='classroom.Student'),
),
migrations.AddField(
model_name='student',
name='quizzes',
field=models.ManyToManyField(through='classroom.TakenQuiz', to='classroom.Quiz'),
),
]

23
django_school/classroom/migrations/0002_create_initial_subjects.py

@ -0,0 +1,23 @@
# Generated by Django 2.0.1 on 2018-01-18 18:07
from django.db import migrations
def create_subjects(apps, schema_editor):
Subject = apps.get_model('classroom', 'Subject')
Subject.objects.create(name='Arts', color='#343a40')
Subject.objects.create(name='Computing', color='#007bff')
Subject.objects.create(name='Math', color='#28a745')
Subject.objects.create(name='Biology', color='#17a2b8')
Subject.objects.create(name='History', color='#ffc107')
class Migration(migrations.Migration):
dependencies = [
('classroom', '0001_initial'),
]
operations = [
migrations.RunPython(create_subjects),
]

0
django_school/classroom/migrations/__init__.py

76
django_school/classroom/models.py

@ -0,0 +1,76 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.html import mark_safe, escape
class User(AbstractUser):
is_student = models.BooleanField(default=False)
is_teacher = models.BooleanField(default=False)
class Subject(models.Model):
name = models.CharField(max_length=30)
color = models.CharField(max_length=7, default='#007bff')
def __str__(self):
return self.name
def get_html_badge(self):
name = escape(self.name)
color = escape(self.color)
html = '<span class="badge badge-primary" style="background-color: %s">%s</span>' % (color, name)
return mark_safe(html)
class Quiz(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quizzes')
name = models.CharField(max_length=255)
subject = models.ForeignKey(Subject, on_delete=models.CASCADE, related_name='quizzes')
def __str__(self):
return self.name
class Question(models.Model):
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='questions')
text = models.CharField('Question', max_length=255)
def __str__(self):
return self.text
class Answer(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers')
text = models.CharField('Answer', max_length=255)
is_correct = models.BooleanField('Correct answer', default=False)
def __str__(self):
return self.text
class Student(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
quizzes = models.ManyToManyField(Quiz, through='TakenQuiz')
interests = models.ManyToManyField(Subject, related_name='interested_students')
def get_unanswered_questions(self, quiz):
answered_questions = self.quiz_answers \
.filter(answer__question__quiz=quiz) \
.values_list('answer__question__pk', flat=True)
questions = quiz.questions.exclude(pk__in=answered_questions).order_by('text')
return questions
def __str__(self):
return self.user.username
class TakenQuiz(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='taken_quizzes')
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='taken_quizzes')
score = models.FloatField()
date = models.DateTimeField(auto_now_add=True)
class StudentAnswer(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='quiz_answers')
answer = models.ForeignKey(Answer, on_delete=models.CASCADE, related_name='+')

18
django_school/classroom/templates/classroom/home.html

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block content %}
<h2>Welcome to the Django Schools! <span class="icon-emo-happy"></span></h2>
<p class="lead">
If you already have an account, go ahead and <a href="{% url 'login' %}">log in</a>. If you are new to Django Schools, get started
by creating a <a href="{% url 'student_signup' %}">student account</a> or a <a href="">teacher account</a>.
</p>
<hr>
<h3>What's this about?</h3>
<p>
This Django application is an example I created to illustrate a <a href="">blog post about how to implement multiple user types</a>.
In this application, users can sign up as a student or a teacher. Teachers can create quizzes and students can answer quizzes
based on their interests.
</p>
<p>Want to run this code locally? <a href="">Read detailed instructions on how to run this project</a>.</p>
<p>Vitor Freitas<br><a href="https://twitter.com/vitorfs/" target="_blank" rel="noopener">@vitorfs</a></p>
{% endblock %}

44
django_school/classroom/templates/classroom/students/quiz_list.html

@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% block content %}
<h2>Quizzes</h2>
<p class="text-muted">Subjects:{% for subject in user.student.interests.all %} {{ subject.get_html_badge }}{% endfor %}</p>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link active" href="{% url 'students:quiz_list' %}">New</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'students:taken_quiz_list' %}">Taken</a>
</li>
</ul>
<div class="card">
<table class="table mb-0">
<thead>
<tr>
<th>Quiz</th>
<th>Subject</th>
<th>Length</th>
<th></th>
</tr>
</thead>
<tbody>
{% for quiz in quizzes %}
<tr>
<td class="align-middle">{{ quiz.name }}</td>
<td class="align-middle">{{ quiz.subject.get_html_badge }}</td>
<td class="align-middle">{{ quiz.questions_count }} questions</td>
<td class="text-right">
<a href="{% url 'students:take_quiz' quiz.pk %}" class="btn btn-primary">Start quiz</a>
</td>
</tr>
{% empty %}
<tr>
<td class="bg-light text-center font-italic" colspan="4">No quiz matching your interests right now.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

13
django_school/classroom/templates/classroom/students/take_quiz_form.html

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<h2 class="mb-3">{{ quiz.name }}</h2>
<p class="lead">{{ question.text }}</p>
<form method="post" novalidate>
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">Next</button>
</form>
{% endblock %}

40
django_school/classroom/templates/classroom/students/taken_quiz_list.html

@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% block content %}
<h2>Quizzes</h2>
<p class="text-muted">Subjects:{% for subject in user.student.interests.all %} {{ subject.get_html_badge }}{% endfor %}</p>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" href="{% url 'students:quiz_list' %}">New</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{% url 'students:taken_quiz_list' %}">Taken</a>
</li>
</ul>
<div class="card">
<table class="table mb-0">
<thead>
<tr>
<th>Quiz</th>
<th>Subject</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{% for taken_quiz in taken_quizzes %}
<tr>
<td>{{ taken_quiz.quiz.name }}</td>
<td>{{ taken_quiz.quiz.subject.get_html_badge }}</td>
<td>{{ taken_quiz.score }}</td>
</tr>
{% empty %}
<tr>
<td class="bg-light text-center font-italic" colspan="3">You haven't completed any quiz yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

21
django_school/classroom/templates/classroom/teachers/question_add_form.html

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change_list' %}">My Quizzes</a></li>
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change' quiz.pk %}">{{ quiz.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Add a new question</li>
</ol>
</nav>
<h2 class="mb-3">Add a new question</h2>
<p class="lead">Add first the text of the question. In the next step you will be able to add the possible answers.</p>
<form method="post" novalidate>
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">Add question</button>
<a href="{% url 'teachers:quiz_change' quiz.pk %}" class="btn btn-outline-secondary" role="button">Nevermind</a>
</form>
{% endblock %}

64
django_school/classroom/templates/classroom/teachers/question_change_form.html

@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% load crispy_forms_tags crispy_forms_filters %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change_list' %}">My Quizzes</a></li>
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change' quiz.pk %}">{{ quiz.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ question.text }}</li>
</ol>
</nav>
<h2 class="mb-3">{{ question.txt }}</h2>
<form method="post" novalidate>
{% csrf_token %}
{{ formset.management_form }}
{{ form|crispy }}
<div class="card mb-3{% if formset.errors %} border-danger{% endif %}">
<div class="card-header">
<div class="row">
<div class="col-8">
<strong>Answers</strong>
</div>
<div class="col-2">
<strong>Correct?</strong>
</div>
<div class="col-2">
<strong>Delete?</strong>
</div>
</div>
</div>
{% for error in formset.non_form_errors %}
<div class="card-body bg-danger border-danger text-white py-2">{{ error }}</div>
{% endfor %}
<div class="list-group list-group-flush list-group-formset">
{% for form in formset %}
<div class="list-group-item">
<div class="row">
<div class="col-8">
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
{{ form.text|as_crispy_field }}
{% if form.instance.pk and form.text.value != form.instance.text %}<p class="mb-0 mt-1"><small class="text-muted font-italic"><strong>Old answer:</strong> {{ form.instance.text }}</small></p>{% endif %}
</div>
<div class="col-2">
{{ form.is_correct }}
</div>
<div class="col-2">
{% if form.instance.pk %}
{{ form.DELETE }}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<p>
<small class="form-text text-muted">Your question may have at least <strong>2</strong> answers and maximum <strong>10</strong> answers. Select at least one correct answer.</small>
</p>
<button type="submit" class="btn btn-primary">Save changes</button>
<a href="{% url 'teachers:quiz_change' quiz.pk %}" class="btn btn-outline-secondary" role="button">Nevermind</a>
</form>
{% endblock %}

23
django_school/classroom/templates/classroom/teachers/quiz_add_form.html

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change_list' %}">My Quizzes</a></li>
<li class="breadcrumb-item active" aria-current="page">Add a new quiz</li>
</ol>
</nav>
<h2 class="mb-3">Add a new quiz</h2>
<div class="row">
<div class="col-md-6 col-sm-8 col-12">
<form method="post" novalidate>
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">Add quiz</button>
<a href="{% url 'teachers:quiz_change_list' %}" class="btn btn-outline-secondary" role="button">Nevermind</a>
</form>
</div>
</div>
{% endblock %}

56
django_school/classroom/templates/classroom/teachers/quiz_change_form.html

@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change_list' %}">My Quizzes</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ quiz.name }}</li>
</ol>
</nav>
<h2 class="mb-3">{{ quiz.name }}</h2>
<div class="row mb-3">
<div class="col-md-6 col-sm-8 col-12">
<form method="post" novalidate>
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">Save changes</button>
<a href="{% url 'teachers:quiz_change_list' %}" class="btn btn-outline-secondary" role="button">Nevermind</a>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="row">
<div class="col-10">
<strong>Questions</strong>
</div>
<div class="col-2">
<strong>Answers</strong>
</div>
</div>
</div>
<div class="list-group list-group-flush list-group-formset">
{% for question in questions %}
<div class="list-group-item">
<div class="row">
<div class="col-10">
<a href="{% url 'teachers:question_change' quiz.pk question.pk %}">{{ question.text }}</a>
</div>
<div class="col-2">
{{ question.answers_count }}
</div>
</div>
</div>
{% empty %}
<div class="list-group-item text-center">
<p class="text-muted font-italic mb-0">You haven't created any questions yet. Go ahead and <a href="{% url 'teachers:question_add' quiz.pk %}">add the first question</a>.</p>
</div>
{% endfor %}
</div>
<div class="card-footer">
<a href="{% url 'teachers:question_add' quiz.pk %}" class="btn btn-primary btn-sm">Add question</a>
</div>
</div>
{% endblock %}

41
django_school/classroom/templates/classroom/teachers/quiz_change_list.html

@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">My Quizzes</li>
</ol>
</nav>
<h2 class="mb-3">My Quizzes</h2>
<a href="{% url 'teachers:quiz_add' %}" class="btn btn-primary mb-3" role="button">Add quiz</a>
<div class="card">
<table class="table mb-0">
<thead>
<tr>
<th>Quiz</th>
<th>Subject</th>
<th>Questions</th>
<th>Taken</th>
<th></th>
</tr>
</thead>
<tbody>
{% for quiz in quizzes %}
<tr>
<td class="align-middle"><a href="{% url 'teachers:quiz_change' quiz.pk %}">{{ quiz.name }}</a></td>
<td class="align-middle">{{ quiz.subject.get_html_badge }}</td>
<td class="align-middle">{{ quiz.questions_count }}</td>
<td class="align-middle">{{ quiz.taken_count }}</td>
<td class="text-right">
<a href="{% url 'teachers:quiz_results' quiz.pk %}" class="btn btn-primary">View results</a>
</td>
</tr>
{% empty %}
<tr>
<td class="bg-light text-center font-italic" colspan="5">You haven't created any quiz yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

42
django_school/classroom/templates/classroom/teachers/quiz_results.html

@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change_list' %}">My Quizzes</a></li>
<li class="breadcrumb-item"><a href="{% url 'teachers:quiz_change' quiz.pk %}">{{ quiz.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Results</li>
</ol>
</nav>
<h2 class="mb-3">{{ quiz.name }} Results</h2>
<div class="card">
<div class="card-header">
<strong>Taken Quizzes</strong>
<span class="badge badge-pill badge-primary float-right">Average Score: {{ quiz_score.average_score }}</span>
</div>
<table class="table mb-0">
<thead>
<tr>
<th>Student</th>
<th>Date</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{% for taken_quiz in taken_quizzes %}
<tr>
<td>{{ taken_quiz.student.user.username }}</td>
<td>{{ taken_quiz.date }}</td>
<td>{{ taken_quiz.score }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card-footer text-muted">
Total respondents: <strong>{{ total_taken_quizzes }}</strong>
</div>
</div>
{% endblock %}

3
django_school/classroom/tests.py

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

24
django_school/classroom/urls.py

@ -0,0 +1,24 @@
from django.contrib import admin
from django.urls import path, include
from .views import classroom, students, teachers
urlpatterns = [
path('', classroom.home, name='home'),
path('students/', include(([
path('', students.QuizListView.as_view(), name='quiz_list'),
path('taken/', students.TakenQuizListView.as_view(), name='taken_quiz_list'),
path('quiz/<int:pk>/', students.take_quiz, name='take_quiz'),
], 'classroom'), namespace='students')),
path('teachers/', include(([
path('', teachers.QuizListView.as_view(), name='quiz_change_list'),
path('quiz/add/', teachers.QuizCreateView.as_view(), name='quiz_add'),
path('quiz/<int:pk>/', teachers.QuizUpdateView.as_view(), name='quiz_change'),
path('quiz/<int:pk>/results/', teachers.QuizResultsView.as_view(), name='quiz_results'),
path('quiz/<int:pk>/question/add/', teachers.question_add, name='question_add'),
path('quiz/<int:quiz_pk>/question/<int:question_pk>/', teachers.question_change, name='question_change'),
], 'classroom'), namespace='teachers')),
]

0
django_school/classroom/views/__init__.py

15
django_school/classroom/views/classroom.py

@ -0,0 +1,15 @@
from django.shortcuts import render, redirect
from django.views.generic import TemplateView
class SignUpView(TemplateView):
template_name = 'registration/signup.html'
def home(request):
if request.user.is_authenticated:
if request.user.is_teacher:
return redirect('teachers:quiz_change_list')
else:
return redirect('students:quiz_list')
return render(request, 'classroom/home.html')

98
django_school/classroom/views/students.py

@ -0,0 +1,98 @@
from django.views.generic import CreateView, ListView, View
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.shortcuts import redirect, render, get_object_or_404
from django.contrib.auth import login
from django.db.models import Count
from django.contrib import messages
from django.db import transaction
from ..decorators import student_required
from ..models import Quiz, User, TakenQuiz
from ..forms import StudentSignUpForm, TakeQuizForm
class StudentSignUpView(CreateView):
model = User
form_class = StudentSignUpForm
template_name = 'registration/signup_form.html'
def get_context_data(self, **kwargs):
kwargs['user_type'] = 'student'
return super().get_context_data(**kwargs)
def form_valid(self, form):
user = form.save()
login(self.request, user)
return redirect('students:quiz_list')
@method_decorator([login_required, student_required], name='dispatch')
class QuizListView(ListView):
model = Quiz
ordering = ('name', )
context_object_name = 'quizzes'
template_name = 'classroom/students/quiz_list.html'
def get_queryset(self):
student = self.request.user.student
student_interests = student.interests.values_list('pk', flat=True)
taken_quizzes = student.quizzes.values_list('pk', flat=True)
queryset = Quiz.objects.filter(subject__in=student_interests) \
.exclude(pk__in=taken_quizzes) \
.annotate(questions_count=Count('questions')) \
.filter(questions_count__gt=0)
return queryset
@method_decorator([login_required, student_required], name='dispatch')
class TakenQuizListView(ListView):
model = TakenQuiz
context_object_name = 'taken_quizzes'
template_name = 'classroom/students/taken_quiz_list.html'
def get_queryset(self):
queryset = self.request.user.student.taken_quizzes \
.select_related('quiz', 'quiz__subject') \
.order_by('quiz__name')
return queryset
@login_required
@student_required
def take_quiz(request, pk):
quiz = get_object_or_404(Quiz, pk=pk)
student = request.user.student
if student.quizzes.filter(pk=pk).exists():
return render(request, 'students/taken_quiz.html')
question = student.get_unanswered_questions(quiz).first()
if not question:
TakenQuiz.objects.create(student=student, quiz=quiz, score=0)
return redirect('students:quiz_list')
if request.method == 'POST':
form = TakeQuizForm(question=question, data=request.POST)
if form.is_valid():
with transaction.atomic():
student_answer = form.save(commit=False)
student_answer.student = student
student_answer.save()
if student.get_unanswered_questions(quiz).exists():
return redirect('students:take_quiz', pk)
else:
total_questions = quiz.questions.count()
correct_answers = student.quiz_answers.filter(answer__question__quiz=quiz, answer__is_correct=True).count()
score = round((correct_answers / total_questions) * 100.0, 2)
TakenQuiz.objects.create(student=student, quiz=quiz, score=score)
messages.success(request, 'Congratulations! You completed the quiz %s with success!' % quiz.name)
return redirect('students:quiz_list')
else:
form = TakeQuizForm(question=question)
return render(request, 'classroom/students/take_quiz_form.html', {
'quiz': quiz,
'question': question,
'form': form
})

172
django_school/classroom/views/teachers.py

@ -0,0 +1,172 @@
from django.views.generic import CreateView, UpdateView, DetailView
from django.views.generic.base import View
from django.contrib.auth.decorators import login_required
from django.views.generic import ListView
from django.utils.decorators import method_decorator
from django.contrib.auth import login
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse, reverse_lazy
from django.db import transaction
from django.db.models import Count, Avg
from django.contrib import messages
from django.forms import inlineformset_factory
from ..decorators import teacher_required
from ..models import User, Quiz, Question, Answer
from ..forms import TeacherSignUpForm, QuestionForm, BaseAnswerInlineFormSet
class TeacherSignUpView(CreateView):
model = User
form_class = TeacherSignUpForm
template_name = 'registration/signup_form.html'
def get_context_data(self, **kwargs):
kwargs['user_type'] = 'teacher'
return super().get_context_data(**kwargs)
def form_valid(self, form):
user = form.save()
login(self.request, user)
return redirect('teachers:quiz_change_list')
@method_decorator([login_required, teacher_required], name='dispatch')
class QuizListView(ListView):
model = Quiz
ordering = ('name', )
context_object_name = 'quizzes'
template_name = 'classroom/teachers/quiz_change_list.html'
def get_queryset(self):
queryset = self.request.user.quizzes \
.select_related('subject') \
.annotate(questions_count=Count('questions'), taken_count=Count('taken_quizzes'))
return queryset
@method_decorator([login_required, teacher_required], name='dispatch')
class QuizCreateView(CreateView):
model = Quiz
fields = ('name', 'subject', )
template_name = 'classroom/teachers/quiz_add_form.html'
def form_valid(self, form):
quiz = form.save(commit=False)
quiz.owner = self.request.user
quiz.save()
messages.success(self.request, 'The quiz was created with success! Go ahead and add some questions now.')
return redirect('teachers:quiz_change', quiz.pk)
@method_decorator([login_required, teacher_required], name='dispatch')
class QuizUpdateView(UpdateView):
model = Quiz
fields = ('name', 'subject', )
context_object_name = 'quiz'
template_name = 'classroom/teachers/quiz_change_form.html'
def get_context_data(self, **kwargs):
kwargs['questions'] = self.get_object().questions.annotate(answers_count=Count('answers'))
return super().get_context_data(**kwargs)
def get_queryset(self):
'''
This method is an implicit object-level permission management
This view will only match the ids of existing quizzes that belongs
to the logged in user.
'''
return self.request.user.quizzes.all()
def get_success_url(self):
return reverse('teachers:quiz_change', kwargs={'pk': self.object.pk})
@method_decorator([login_required, teacher_required], name='dispatch')
class QuizResultsView(DetailView):
model = Quiz
context_object_name = 'quiz'
template_name = 'classroom/teachers/quiz_results.html'
def get_context_data(self, **kwargs):
quiz = self.get_object()
taken_quizzes = quiz.taken_quizzes.select_related('student__user').order_by('date')
total_taken_quizzes = taken_quizzes.count()
quiz_score = quiz.taken_quizzes.aggregate(average_score=Avg('score'))
extra_context = {
'taken_quizzes': taken_quizzes,
'total_taken_quizzes': total_taken_quizzes,
'quiz_score': quiz_score
}
kwargs.update(extra_context)
return super().get_context_data(**kwargs)
def get_queryset(self):
return self.request.user.quizzes.all()
@login_required
@teacher_required
def question_add(request, pk):
# By filtering the quiz by the url keyword argument `pk` and
# by the owner, which is the logged in user, we are protecting
# this view at the object-level. Meaning only the owner of
# quiz will be able to add questions to it.
quiz = get_object_or_404(Quiz, pk=pk, owner=request.user)
if request.method == 'POST':
form = QuestionForm(request.POST)
if form.is_valid():
question = form.save(commit=False)
question.quiz = quiz
question.save()
messages.success(request, 'You may now add answers/options to the question.')
return redirect('teachers:question_change', quiz.pk, question.pk)
else:
form = QuestionForm()
return render(request, 'classroom/teachers/question_add_form.html', {'quiz': quiz, 'form': form})
@login_required
@teacher_required
def question_change(request, quiz_pk, question_pk):
# Simlar to the `question_add` view, this view is also managing
# the permissions at object-level. By querying both `quiz` and
# `question` we are making sure only the owner of the quiz can
# change its details and also only questions that belongs to this
# specific quiz can be changed via this url (in cases where the
# user might have forged/player with the url params.
quiz = get_object_or_404(Quiz, pk=quiz_pk, owner=request.user)
question = get_object_or_404(Question, pk=question_pk, quiz=quiz)
AnswerFormSet = inlineformset_factory(
Question, # parent model
Answer, # base model
formset=BaseAnswerInlineFormSet,
fields=('text', 'is_correct'),
min_num=2,
validate_min=True,
max_num=10,
validate_max=True
)
if request.method == 'POST':
form = QuestionForm(request.POST, instance=question)
formset = AnswerFormSet(request.POST, instance=question)
if form.is_valid() and formset.is_valid():
with transaction.atomic():
form.save()
formset.save()
messages.success(request, 'Question and answers saved with success!')
return redirect('teachers:quiz_change', quiz.pk)
else:
form = QuestionForm(instance=question)
formset = AnswerFormSet(instance=question)
return render(request, 'classroom/teachers/question_change_form.html', {
'quiz': quiz,
'question': question,
'form': form,
'formset': formset
})

0
django_school/django_school/__init__.py

142
django_school/django_school/settings.py

@ -0,0 +1,142 @@
"""
Django settings for django_school project.
Generated by 'django-admin startproject' using Django 2.0.1.
For more information on this file, see
https://docs.djangoproject.com/en/2.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/
"""
import os
from django.contrib.messages import constants as messages
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'd$pxg6fisc4iwzk&vz^s_d0lkf&k63l5a8f!obktw!jg#4zvp3'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'crispy_forms',
'classroom',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'django_school.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates')
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'django_school.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static'),
]
# Custom Django auth settings
AUTH_USER_MODEL = 'classroom.User'
LOGIN_URL = 'login'
LOGOUT_URL = 'logout'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home'
# Messages built-in framework
MESSAGE_TAGS = {
messages.DEBUG: 'alert-secondary',
messages.INFO: 'alert-info',
messages.SUCCESS: 'alert-success',
messages.WARNING: 'alert-warning',
messages.ERROR: 'alert-danger',
}
# Third party apps configuration
CRISPY_TEMPLATE_PACK = 'bootstrap4'

13
django_school/django_school/urls.py

@ -0,0 +1,13 @@
from django.contrib import admin
from django.urls import path, include
from classroom.views import classroom, students, teachers
urlpatterns = [
path('', include('classroom.urls')),
path('accounts/', include('django.contrib.auth.urls')),
path('accounts/signup/', classroom.SignUpView.as_view(), name='signup'),
path('accounts/signup/student/', students.StudentSignUpView.as_view(), name='student_signup'),
path('accounts/signup/teacher/', teachers.TeacherSignUpView.as_view(), name='teacher_signup'),
path('admin/', admin.site.urls),
]

16
django_school/django_school/wsgi.py

@ -0,0 +1,16 @@
"""
WSGI config for django_school project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_school.settings")
application = get_wsgi_application()

15
django_school/manage.py

@ -0,0 +1,15 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_school.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)

32
django_school/static/css/app.css

@ -0,0 +1,32 @@
.logo {
font-family: 'Clicker Script', cursive;
}
.logo a {
color: #212529;
text-decoration: none;
}
footer {
font-size: .9rem;
}
footer a {
color: #212529;
}
.list-group-formset label {
display: none;
}
.list-group-formset .form-group,
.list-group-formset .invalid-feedback {
margin-bottom: 0;
}
.list-group-formset .form-control {
padding: .25rem .5rem;
font-size: .875rem;
line-height: 1.5;
border-radius: .2rem;
}

3
django_school/static/css/students.css

@ -0,0 +1,3 @@
body {
background-color: #c2d4d8;
}

3
django_school/static/css/teachers.css

@ -0,0 +1,3 @@
body {
background-color: #b0aac2;
}

21
django_school/static/vendor/fontello-2f186091/LICENSE.txt

@ -0,0 +1,21 @@
Font license info
## Fontelico
Copyright (C) 2012 by Fontello project
Author: Crowdsourced, for Fontello project
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://fontello.com
## Entypo
Copyright (C) 2012 by Daniel Bruce
Author: Daniel Bruce
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://www.entypo.com

75
django_school/static/vendor/fontello-2f186091/README.txt

@ -0,0 +1,75 @@
This webfont is generated by http://fontello.com open source project.
================================================================================
Please, note, that you should obey original font licenses, used to make this
webfont pack. Details available in LICENSE.txt file.
- Usually, it's enough to publish content of LICENSE.txt file somewhere on your
site in "About" section.
- If your project is open-source, usually, it will be ok to make LICENSE.txt
file publicly available in your repository.
- Fonts, used in Fontello, don't require a clickable link on your site.
But any kind of additional authors crediting is welcome.
================================================================================
Comments on archive content
---------------------------
- /font/* - fonts in different formats
- /css/* - different kinds of css, for all situations. Should be ok with
twitter bootstrap. Also, you can skip <i> style and assign icon classes
directly to text elements, if you don't mind about IE7.
- demo.html - demo file, to show your webfont content
- LICENSE.txt - license info about source fonts, used to build your one.
- config.json - keeps your settings. You can import it back into fontello
anytime, to continue your work
Why so many CSS files ?
-----------------------
Because we like to fit all your needs :)
- basic file, <your_font_name>.css - is usually enough, it contains @font-face
and character code definitions
- *-ie7.css - if you need IE7 support, but still don't wish to put char codes
directly into html
- *-codes.css and *-ie7-codes.css - if you like to use your own @font-face
rules, but still wish to benefit from css generation. That can be very
convenient for automated asset build systems. When you need to update font -
no need to manually edit files, just override old version with archive
content. See fontello source code for examples.
- *-embedded.css - basic css file, but with embedded WOFF font, to avoid
CORS issues in Firefox and IE9+, when fonts are hosted on the separate domain.
We strongly recommend to resolve this issue by `Access-Control-Allow-Origin`
server headers. But if you ok with dirty hack - this file is for you. Note,
that data url moved to separate @font-face to avoid problems with <IE9, when
string is too long.
- animate.css - use it to get ideas about spinner rotation animation.
Attention for server setup
--------------------------
You MUST setup server to reply with proper `mime-types` for font files -
otherwise some browsers will fail to show fonts.
Usually, `apache` already has necessary settings, but `nginx` and other
webservers should be tuned. Here is list of mime types for our file extensions:
- `application/vnd.ms-fontobject` - eot
- `application/x-font-woff` - woff
- `application/x-font-ttf` - ttf
- `image/svg+xml` - svg

28
django_school/static/vendor/fontello-2f186091/config.json

@ -0,0 +1,28 @@
{
"name": "",
"css_prefix_text": "icon-",
"css_use_suffix": false,
"hinting": true,
"units_per_em": 1000,
"ascent": 850,
"glyphs": [
{
"uid": "c64623255a4a7c72436b199b05296c4f",
"css": "emo-happy",
"code": 59393,
"src": "fontelico"
},
{
"uid": "bcb868184ff9c35f8aef564f50c0d649",
"css": "feather",
"code": 59400,
"src": "entypo"
},
{
"uid": "ef74ff62feda486fd414410e782b598a",
"css": "graduation-cap-1",
"code": 59395,
"src": "entypo"
}
]
}

85
django_school/static/vendor/fontello-2f186091/css/animation.css

@ -0,0 +1,85 @@
/*
Animation example, for spinners
*/
.animate-spin {
-moz-animation: spin 2s infinite linear;
-o-animation: spin 2s infinite linear;
-webkit-animation: spin 2s infinite linear;
animation: spin 2s infinite linear;
display: inline-block;
}
@-moz-keyframes spin {
0% {
-moz-transform: rotate(0deg);
-o-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-moz-transform: rotate(359deg);
-o-transform: rotate(359deg);
-webkit-transform: rotate(359deg);
transform: rotate(359deg);
}
}
@-webkit-keyframes spin {
0% {
-moz-transform: rotate(0deg);
-o-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-moz-transform: rotate(359deg);
-o-transform: rotate(359deg);
-webkit-transform: rotate(359deg);
transform: rotate(359deg);