├── src └── buza │ ├── __init__.py │ ├── migrations │ ├── __init__.py │ ├── 0005_subject_short_title.py │ ├── 0003_question_grade.py │ ├── 0004_remove_question_topics.py │ ├── 0002_add_question_topics.py │ └── 0001_initial.py │ ├── apps.py │ ├── static │ └── buza │ │ ├── images │ │ └── buza-logo.png │ │ └── css │ │ ├── fonts │ │ └── GothamRounded-Light.ttf │ │ ├── site.css │ │ ├── questions.css │ │ ├── login.css │ │ └── subjects.css │ ├── templates │ ├── registration │ │ ├── password_change_done.html │ │ ├── password_reset_email.html │ │ ├── logged_out.html │ │ ├── password_reset_complete.html │ │ ├── password_reset_done.html │ │ ├── password_change_form.html │ │ ├── password_reset_form.html │ │ ├── password_reset_confirm.html │ │ └── login.html │ ├── accounts │ │ ├── edit.html │ │ ├── register_done.html │ │ ├── register.html │ │ ├── privacy_policy.html │ │ └── terms_of_service.html │ ├── buza │ │ ├── user_form.html │ │ ├── question_form.html │ │ ├── question_list.html │ │ ├── answer_form.html │ │ ├── subject_detail.html │ │ ├── user_detail.html │ │ └── question_detail.html │ ├── 404.html │ ├── includes │ │ ├── question_card_brief.html │ │ ├── question_card_full.html │ │ └── subject_cards.html │ └── base.html │ ├── settings_tox.py │ ├── settings_env_dev.py │ ├── settings_env.py │ ├── settings_docker.py │ ├── urls.py │ ├── admin.py │ ├── settings_base.py │ ├── models.py │ └── views.py ├── package.json ├── setup.cfg ├── .flake8 ├── examples ├── README.md └── example-data.json ├── .dockerignore ├── .coveragerc ├── yarn.lock ├── update-requirements.sh ├── .env.example ├── Pipfile ├── requirements-pipenv-dev.txt ├── .isort.cfg ├── requirements-pipenv.txt ├── docker-compose.yml ├── mypy.ini ├── tox.ini ├── Dockerfile ├── setup.py ├── .gitignore ├── .travis.yml ├── tests ├── test_settings_env.py ├── test_models.py └── test_views.py ├── README.rst ├── Vagrantfile └── Pipfile.lock /src/buza/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/buza/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "bootstrap": "^4.1.3" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = F403,E402 3 | exclude = ve,docs,local.py,migrations,gem/wagtailsearch/* 4 | -------------------------------------------------------------------------------- /src/buza/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BuzaConfig(AppConfig): 5 | name = 'buza' 6 | -------------------------------------------------------------------------------- /src/buza/static/buza/images/buza-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buza-project/buza-website/HEAD/src/buza/static/buza/images/buza-logo.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # https://flake8.readthedocs.org/en/stable/config.html 2 | [flake8] 3 | exclude = .eggs,.tox,build,migrations 4 | max-line-length = 88 5 | -------------------------------------------------------------------------------- /src/buza/static/buza/css/fonts/GothamRounded-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buza-project/buza-website/HEAD/src/buza/static/buza/css/fonts/GothamRounded-Light.ttf -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Command to regenerate this data dump: 2 | 3 | django-admin dumpdata -e admin.logentry -e auth.permission -e contenttypes -e sessions --natural-foreign --natural-primary --indent 2 -o example-data.json 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !.env.example 3 | !.git 4 | !Pipfile 5 | !Pipfile.lock 6 | !README.rst 7 | !buza-instance 8 | examples 9 | !mypy.ini 10 | !package.json 11 | !setup.cfg 12 | !setup.py 13 | !src 14 | tests 15 | !yarn.lock 16 | *.pyc 17 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # http://coverage.readthedocs.io/en/latest/config.html 2 | 3 | [run] 4 | branch = True 5 | 6 | source = 7 | src 8 | tests 9 | 10 | omit = 11 | # TODO: Not covering this right now. 12 | src/buza/settings_env_dev.py 13 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | bootstrap@^4.1.3: 6 | version "4.1.3" 7 | resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" 8 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {%block title%} Password changed{% endblock %} 4 | {% block content %} 5 | 6 |

Password changed

7 |

Your password has been successfuly changed

8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_reset_email.html: -------------------------------------------------------------------------------- 1 | 2 | Hello {{ user.get_username}} 3 | 4 | We have recieved a password request from you {{ email }}. Follow the link below: 5 | 6 | {{ protocol }}: //{{ domain }} {% url "password_confirm" uidb64=uid toke=token %} 7 | 8 | Regards 9 | The Buza Team -------------------------------------------------------------------------------- /update-requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | # 3 | # Update requirements files from Pipfile.lock 4 | 5 | # Don't include '-e .' in the main requirements file: this breaks things. 6 | pipenv lock --requirements | grep -vF '-e .' >requirements-pipenv.txt 7 | pipenv lock --requirements --dev >requirements-pipenv-dev.txt 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # This is an example .env file for buza-website. 2 | # 3 | # All variables listed here will be loaded into the environment by pipenv. 4 | # 5 | # For more information, see: 6 | # https://docs.pipenv.org/advanced/#automatic-loading-of-env 7 | 8 | DJANGO_SETTINGS_MODULE = buza.settings_env_dev 9 | 10 | -------------------------------------------------------------------------------- /src/buza/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {%block title%} Log-out {%endblock%} 4 | 5 | {% block content%} 6 |

Good bye

7 | 8 |

Your have been succesfully logged out. You can log-in again

9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /src/buza/templates/accounts/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block title %}Edit you account{% endblock %} 5 | {% block content %} 6 |

Edit your account

7 |

You can update your details below:

8 | 9 | {% crispy form %} 10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/buza/settings_tox.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings module buza-website for Tox. 3 | """ 4 | 5 | from buza.settings_base import * # noqa: F401 6 | 7 | 8 | SECRET_KEY = 'buza-website Tox' 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | }, 14 | } 15 | 16 | STATIC_URL = '/static/' 17 | -------------------------------------------------------------------------------- /src/buza/templates/accounts/register_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Created your account{% endblock %} 4 | {% block content %} 5 |

Welcome {{new_user.first_name}}

6 |

Your account was successfully created. Now you can 7 | log in

8 | 9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} Password reset{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

New Password set

8 | 9 |

Your new password has been set. You can log in here.

10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {%block title%} Reset your password {% endblock %} 4 | {% block content %} 5 |

Reset your password

6 |

We have sent you instructions for setting your password

7 |

If you have not recieved an email or SMS please check your contact details

8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/buza/templates/buza/user_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Create an account{% endblock %} 4 | {% block content %} 5 |

Create an Buza account

6 |

Please, sign up using the following form

7 | 8 |
9 | {{form}} 10 | {% csrf_token%} 11 |

12 | 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | buza-website = {editable = true,path = "."} 8 | humanize = "*" 9 | psycopg2-binary = "*" 10 | 11 | [dev-packages] 12 | "flake8" = "*" 13 | isort = "*" 14 | mypy = "*" 15 | "flake8-commas" = "*" 16 | pytest = "*" 17 | pytest-django = "*" 18 | setuptools-scm = "*" 19 | 20 | [requires] 21 | python_version = "3.6" 22 | -------------------------------------------------------------------------------- /src/buza/templates/accounts/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Create an account{% endblock %} 4 | {% block content %} 5 |

Create an Buza account

6 |

Please, sign up using the following form

7 | 8 |
9 | {{user_form.as_p}} 10 | {% csrf_token%} 11 |

12 | 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} Change your password {% endblock %} 4 | 5 | {% block content %} 6 |

Change your password

7 |

Enter your old password then the new one

8 |
9 | {{ form.as_p }} 10 |

11 | {% csrf_token %} 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /src/buza/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% block title %}Page not found 404 {{ block.super }}{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

We could not find the page you were looking for

8 |

😭

9 |

Take me home

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /requirements-pipenv-dev.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | atomicwrites==1.2.1 3 | attrs==18.2.0 4 | filelock==3.0.10 5 | flake8-commas==2.0.0 6 | flake8==3.6.0 7 | isort==4.3.4 8 | mccabe==0.6.1 9 | more-itertools==5.0.0 10 | mypy-extensions==0.4.1 11 | mypy==0.650 12 | pluggy==0.8.1 13 | py==1.7.0 14 | pycodestyle==2.4.0 15 | pyflakes==2.0.0 16 | pytest-django==3.4.5 17 | pytest==4.1.1 18 | setuptools-scm==3.1.0 19 | six==1.12.0 20 | typed-ast==1.1.2 21 | virtualenv==16.2.0 22 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_reset_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} Reset your password {% endblock %} 4 | {% block content %} 5 |

Forgotten your password

6 |

Enter your e-mail address or cell phone number to get a new password

7 | 8 |
9 | {{ form.as_p }} 10 |

11 | {% csrf_token %} 12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # 3 | # pip install isort 4 | # isort --check-only 5 | # isort --diff 6 | # isort --apply 7 | 8 | [settings] 9 | atomic = True 10 | line_length = 88 11 | lines_after_imports = 2 12 | skip = 13 | # NOTE: Do not add "build" here: that inadvertently excludes the entire Travis build directory. 14 | .eggs 15 | .hg 16 | .tox 17 | migrations 18 | # List these explicitly, so isort behaves the same when invoked from Tox. 19 | known_first_party = 20 | buza 21 | project 22 | -------------------------------------------------------------------------------- /src/buza/migrations/0005_subject_short_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-11-15 17:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('buza', '0004_remove_question_topics'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='subject', 15 | name='short_title', 16 | field=models.TextField(default='EMS'), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /requirements-pipenv.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | certifi==2018.11.29 3 | chardet==3.0.4 4 | defusedxml==0.5.0 ; python_version >= '3.0' 5 | django-crispy-forms==1.7.2 6 | django-environ==0.4.5 7 | django-taggit==0.23.0 8 | django==2.1.5 9 | humanize==0.5.1 10 | idna==2.8 11 | oauthlib==3.0.0 12 | pillow==5.4.1 13 | pyjwt==1.7.1 14 | python3-openid==3.1.0 ; python_version >= '3.0' 15 | pytz==2018.9 16 | requests-oauthlib==1.2.0 17 | requests==2.21.0 18 | six==1.12.0 19 | social-auth-app-django==3.1.0 20 | social-auth-core==3.0.0 21 | urllib3==1.24.1 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | db: 5 | image: postgres:10.1-alpine 6 | volumes: 7 | - postgres_data:/var/lib/postgresql/data/ 8 | web: 9 | environment: 10 | - DJANGO_SETTINGS_MODULE=buza.settings_docker 11 | env_file: 12 | - secrets.py 13 | image: buzaproject/buza-answers:latest 14 | volumes: 15 | - .:/code 16 | container_name: buza-container 17 | ports: 18 | - 8000:8000 19 | command: django-admin runserver 0.0.0.0:8000 20 | depends_on: 21 | - db 22 | 23 | volumes: 24 | postgres_data 25 | -------------------------------------------------------------------------------- /src/buza/templates/buza/question_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% load crispy_forms_tags %} 5 | 6 | {% block head_extra %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block title %}Ask a question - {{ block.super }}{% endblock %} 12 | 13 | {% block content %} 14 |
15 | 16 |

{% if question.pk %}Edit{% else %}Add a new{% endif %} Question

17 | 18 | {% crispy form %} 19 | 20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /src/buza/templates/registration/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} Reset your password {% endblock %} 4 | 5 | {% block content %} 6 | 7 |

Reset your password

8 | {% if validlink %} 9 | 10 |

Please enter your new password twice

11 | 12 |
13 | {{ form.as_p }} 14 | {% csrf_token %} 15 |

16 | 17 |
18 | {% else %} 19 |

The password resent link is invalid. It may have been used before

20 | {% endif %} 21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # 3 | # pip install mypy 4 | # mypy src tests 5 | # 6 | # With coverage output: 7 | # 8 | # pip install mypy lxml 9 | # mypy --html-report build/mypy_coverage src tests 10 | 11 | [mypy] 12 | ignore_missing_imports = True 13 | strict_optional = True 14 | 15 | # --strict flags: 16 | disallow_untyped_calls = False 17 | disallow_untyped_defs = False 18 | disallow_incomplete_defs = True 19 | check_untyped_defs = True 20 | disallow_subclassing_any = False 21 | disallow_untyped_decorators = True 22 | warn_redundant_casts = True 23 | warn_return_any = True 24 | warn_unused_ignores = True 25 | warn_unused_configs = True 26 | no_implicit_optional = True 27 | -------------------------------------------------------------------------------- /src/buza/migrations/0003_question_grade.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-21 16:07 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('buza', '0002_add_question_topics'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='question', 16 | name='grade', 17 | field=models.IntegerField(default=7, help_text='Which grade it this question most relevant for? \nBy default this should be the grade that you are in.', validators=[django.core.validators.MinValueValidator(7), django.core.validators.MaxValueValidator(12)]), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py36 8 | 9 | [testenv] 10 | passenv = TOXENV CI TRAVIS TRAVIS_* 11 | 12 | deps = 13 | -r requirements-pipenv.txt 14 | -r requirements-pipenv-dev.txt 15 | 16 | codecov: pytest-cov 17 | codecov: codecov 18 | 19 | setenv = 20 | DJANGO_SETTINGS_MODULE = buza.settings_tox 21 | codecov: PYTEST_ADDOPTS = --cov 22 | 23 | # XXX: Is there any better way than this to get coverage paths reported right? 24 | usedevelop = 25 | codecov: true 26 | 27 | commands = 28 | django-admin check 29 | pytest 30 | codecov: codecov 31 | mypy src tests 32 | flake8 33 | isort --check-only --diff 34 | -------------------------------------------------------------------------------- /src/buza/templates/buza/question_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block head_extra %} 5 | {{ block.super }} 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block title %}Questions - {{ block.super }}{% endblock %} 11 | 12 | {% block content %} 13 |
14 |
15 | {% for subject in subject_list %} 16 | {% include 'includes/subject_cards.html' with subject=subject user=user csrf_token=csrf_token only%} 17 | {% endfor %} 18 |
19 |
20 | {% for question in question_list %} 21 | {% include 'includes/question_card_brief.html' with question=question only %} 22 | {% endfor %} 23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /src/buza/templates/buza/answer_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% comment %} 3 | Expected context: 4 | 5 | * question: The question being answered 6 | 7 | {% endcomment %} 8 | {% load static %} 9 | 10 | {% block head_extra %} 11 | {{ block.super }} 12 | 13 | {% endblock %} 14 | 15 | {% block title %}Answer question - {{ block.super }}{% endblock %} 16 | 17 | {% block content %} 18 | 19 | {% include 'includes/question_card_full.html' with question=question only %} 20 | 21 |
{# FIXME #} 26 | {% csrf_token %} 27 | {{ form.as_p }} 28 |

29 | 30 |

31 |
32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM python:3.6 3 | 4 | # The enviroment variable ensures that the python output is set straight 5 | # to the terminal with out buffering it first 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | RUN mkdir /buza-website 9 | 10 | RUN set -ex; \ 11 | apt-get update; \ 12 | apt-get install apt-transport-https; \ 13 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -; \ 14 | echo "deb https://dl.yarnpkg.com/debian/ stable main" >/etc/apt/sources.list.d/yarn.list; \ 15 | curl -sL https://deb.nodesource.com/setup_10.x -o nodesource_setup.sh; \ 16 | bash nodesource_setup.sh; \ 17 | apt-get update; \ 18 | apt-get install -y python3-pip yarn nodejs 19 | 20 | 21 | WORKDIR /buza-website 22 | 23 | # Copy the current directory contents into the container 24 | COPY . /buza-website 25 | 26 | RUN set -ex; \ 27 | pip install pipenv; \ 28 | yarn; \ 29 | cp -p .env.example .env; 30 | 31 | ENV DJANGO_SETTINGS_MODULE="buza.settings_docker" 32 | 33 | RUN pipenv install --system --deploy; \ 34 | pipenv run django-admin migrate 35 | 36 | EXPOSE 8000 37 | -------------------------------------------------------------------------------- /src/buza/templates/includes/question_card_brief.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% comment %} 3 | A question card with only brief details. 4 | 5 | Expected context: 6 | 7 | * question: The question being viewed 8 | {% endcomment %} 9 |
10 |
11 | 12 | @{{ question.author.username }} 13 | 14 | {{question.subject}} 15 |

16 | {{ question.created | naturaltime}}

17 |
18 | 19 | {{ question.title|linebreaks|truncatewords:50 }} 20 | 21 |
22 | {{ question.answer_set.all.0.body|truncatewords:50}} 23 |
24 | 25 |
26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | with open(os.path.join(here, 'README.rst')) as f: 8 | README = f.read() 9 | 10 | 11 | setup( 12 | name='buza-website', 13 | description='buza', 14 | long_description=README, 15 | classifiers=[ 16 | 'Programming Language :: Python', 17 | 'Topic :: Internet :: WWW/HTTP', 18 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', 19 | ], 20 | author='Ctrl Space', 21 | author_email='dev@buza.com', 22 | url='None', 23 | license='BSD', 24 | 25 | package_dir={'': 'src'}, 26 | packages=find_packages('src'), 27 | include_package_data=True, 28 | zip_safe=False, 29 | 30 | setup_requires=['setuptools-scm'], 31 | use_scm_version=True, 32 | 33 | install_requires=[ 34 | 'Django ~=2.1.0', 35 | 36 | # General libraries 37 | 'Pillow', 38 | 39 | # Django libraries 40 | 'django-crispy-forms', 41 | 'django-environ', 42 | 'django-taggit', 43 | 'social-auth-app-django', 44 | ], 45 | entry_points={}, 46 | ) 47 | -------------------------------------------------------------------------------- /src/buza/templates/includes/question_card_full.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% comment %} 3 | A question card with full details. 4 | 5 | Expected context: 6 | 7 | * question: The question being viewed 8 | * user (Optional): The viewing user 9 | {% endcomment %} 10 |
11 |
12 | 13 | @{{ question.author.username }} 14 | 15 | {{question.subject}} 16 |

17 | {{ question.created | naturaltime}}

18 |
19 | 20 | {{ question.title|linebreaks}} 21 | 22 | {% if user == question.author %} 23 |

24 | 25 | Edit question 26 | 27 |

28 | {% endif %} 29 |
30 | -------------------------------------------------------------------------------- /src/buza/settings_env_dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Like `settings_env`, but set more development defaults. 3 | 4 | The idea is that ``DJANGO_SETTINGS_MODULE=buza.settings_env_dev`` 5 | should provide a usable starting development configuration 6 | but still allow overriding any individual settings. 7 | """ 8 | import os 9 | from pathlib import Path 10 | 11 | import environ 12 | 13 | 14 | env = environ.Env() 15 | 16 | # Assume we're running from a Git checkout directory. 17 | checkout_dir: Path = Path(__file__).parent.parent.parent 18 | assert checkout_dir.joinpath('.git').exists(), checkout_dir 19 | 20 | # If BASE_DIR is not set, set and create a default for it. 21 | if 'BASE_DIR' not in os.environ: 22 | os.environ['BASE_DIR'] = str(checkout_dir.joinpath('buza-instance')) 23 | Path(os.environ['BASE_DIR']).mkdir(exist_ok=True) 24 | 25 | # Set a few more defaults for development. 26 | os.environ.setdefault('DJANGO_SECRET_KEY', 'buza-website example dev') 27 | os.environ.setdefault('DJANGO_DEBUG', 'True') 28 | 29 | STATICFILES_DIRS = [ 30 | # Path to Yarn's packages 31 | str(checkout_dir.joinpath('node_modules')), 32 | ] 33 | 34 | # Include the local host by default for development. 35 | 36 | from buza.settings_env import * # noqa: F401 isort:skip 37 | 38 | ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] 39 | DEBUG = True 40 | -------------------------------------------------------------------------------- /src/buza/static/buza/css/site.css: -------------------------------------------------------------------------------- 1 | /* Site-wide Buza styles. */ 2 | 3 | :root{ 4 | --buza-blue: #5689E8; 5 | --buza-light-blue: #3BC9EC; 6 | --buza-green: #62DD7F; 7 | --buza-grey: #A6C8C6; 8 | --buza-light-green: #77DB8F; 9 | --buza-light-purple: #C6B9CA; 10 | --buza-purple: #B293BC; 11 | } 12 | 13 | @font-face { 14 | font-family: buzaFont; 15 | src: url(fonts/GothamRounded-Light.ttf); 16 | } 17 | 18 | .bg-buza-site { 19 | background-color: --var(buza-grey); 20 | font-family: buzaFont; 21 | } 22 | 23 | .buza-green_text{ 24 | color: var(--buza-green); 25 | } 26 | 27 | .buza-blue_text{ 28 | color: var(--buza-blue); 29 | } 30 | .buza-grey_text{ 31 | color: var(--buza-grey); 32 | } 33 | 34 | .buza-purple_text{ 35 | color: var(--buza-purple); 36 | } 37 | /* Block: Navigation bar. */ 38 | .buza-nav { 39 | background: linear-gradient(to bottom right, #17EAD9, #6078EA), #5689E8; 40 | color: white; 41 | /* A bit of space before the content. */ 42 | padding-top: 0.5em; 43 | padding-bottom: 0.5em; 44 | margin-bottom: 0.5em; 45 | } 46 | /* Element: Navigation bar links. */ 47 | .buza-nav__link { 48 | display: block; 49 | text-align: center; 50 | } 51 | .buza-nav__link, .buza-nav__link:hover { 52 | color: white; 53 | } 54 | 55 | .buza-container { 56 | margin-left: 0; 57 | max-width: 100%; 58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Default Django instance directory for development. 4 | /buza-instance/ 5 | 6 | # dependencies 7 | /node_modules 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | *.py[cod] 27 | 28 | # C extensions 29 | *.so 30 | 31 | # Packages 32 | *.egg 33 | *.egg-info 34 | .eggs/ 35 | dist 36 | build 37 | eggs 38 | parts 39 | bin 40 | var 41 | sdist 42 | develop-eggs 43 | .installed.cfg 44 | lib 45 | lib64 46 | 47 | # Installer logs 48 | pip-log.txt 49 | 50 | # Unit test / coverage reports 51 | .coverage 52 | .tox 53 | nosetests.xml 54 | 55 | # Translations 56 | *.mo 57 | 58 | # Mr Developer 59 | .mr.developer.cfg 60 | .project 61 | .pydevproject 62 | ve/ 63 | /ve/ 64 | logs/*.log 65 | *.pid 66 | .vscode/ 67 | 68 | local.py 69 | /static/ 70 | /media/ 71 | db.sqlite3 72 | db.sqlite4 73 | docs/_build 74 | .DS_Store 75 | .cache/ 76 | .mypy_cache/ 77 | .pytest_cache/ 78 | /htmlcov/ 79 | 80 | celerybeat-schedule 81 | celerybeat-schedule.db 82 | 83 | */settings/secrets.py 84 | 85 | # PyCharm / IntelliJ 86 | .idea/ 87 | gem_test.db 88 | 89 | # test db 90 | gem_test.db 91 | node_modules/ 92 | db.sqlite3-journal 93 | *.pyc 94 | 95 | # Vagrant 96 | .vagrant/ 97 | -------------------------------------------------------------------------------- /src/buza/templates/buza/subject_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block head_extra %} 5 | {{ block.super }} 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block title %}{{ subject.title }} - {{ block.super }}{% endblock %} 11 | 12 | {% block content %} 13 |
14 |
15 | {% for view_subject in subject_list %} 16 | {% include 'includes/subject_cards.html' with subject=view_subject user=user csrf_token=csrf_token selected_subject=subject only%} 17 | {% endfor %} 18 |
19 |
20 | 21 | 22 | Ask New {{subject.short_title}} Question 23 | 24 | 25 | {% with questions=subject.question_set.all %} 26 | {% if questions %} 27 | {% for question in questions %} 28 | {% include 'includes/question_card_brief.html' with question=question only %} 29 | {% endfor %} 30 | {% endif %} 31 | {% endwith %} 32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /src/buza/settings_env.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings module for buza-website using django-environ. 3 | """ 4 | import os 5 | 6 | import environ 7 | 8 | from buza.settings_base import * # noqa: F401 9 | 10 | 11 | env = environ.Env() 12 | 13 | # Obtain a base instance directory. 14 | base_dir = env.path('BASE_DIR') 15 | 16 | 17 | DEBUG = env('DJANGO_DEBUG', default=False) 18 | SECRET_KEY = env('DJANGO_SECRET_KEY') 19 | SOCIAL_AUTH_RAISE_EXCEPTIONS = env('SOCIAL_AUTH_RAISE_EXCEPTIONS', default=False) 20 | SOCIAL_AUTH_FACEBOOK_API_VERSION = '2.8' 21 | LOGIN_ERROR_URL = '/' 22 | 23 | DATABASES = { 24 | 'default': env.db( 25 | 'DJANGO_DATABASE_URL', 26 | default=f'sqlite:///' + base_dir('buza.sqlite3'), 27 | ), 28 | } 29 | 30 | STATIC_ROOT = env('DJANGO_STATIC_ROOT', default=base_dir('static_root')) 31 | STATIC_URL = env('DJANGO_STATIC_URL', default='/static/') 32 | 33 | MEDIA_ROOT = env('DJANGO_MEDIA_ROOT', default=base_dir('media_root')) 34 | MEDIA_URL = env('DJANGO_MEDIA_URL', default='/media/') 35 | 36 | # Internationalization 37 | LANGUAGE_CODE = env('DJANGO_LANGUAGE_CODE', default='en-ZA') 38 | TIME_ZONE = env('DJANGO_TIME_ZONE', default='Africa/Johannesburg') 39 | 40 | # social auth keys 41 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get("GoogleKey", "none") 42 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get("GoogleSecret", "none") 43 | 44 | SOCIAL_AUTH_FACEBOOK_KEY = os.environ.get("FbKey", "none") 45 | SOCIAL_AUTH_FACEBOOK_SECRET = os.environ.get("FbSecret", "none") 46 | 47 | ALLOWED_HOSTS = ['localhost'] 48 | -------------------------------------------------------------------------------- /src/buza/templates/buza/user_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block head_extra %} 5 | {{ block.super }} 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block title %}{{ user_object.get_full_name }} - {{ block.super }}{% endblock %} 11 | 12 | {% block content %} 13 |
14 | 15 | {# Show account actions for the signed-in user. #} 16 | {% if user_object == user %} 17 |

18 | Edit Account 19 | Change Password 20 | Log out 21 |

22 | {% endif %} 23 | 24 |

{{ user_object.get_full_name }}

25 | 26 |

27 | 28 | @{{ user_object.username }} 29 | 30 |

31 | 32 | {% if user_object.bio %} 33 |

{{ user_object.bio }}

34 | {% endif %} 35 |
36 | 37 | {# TODO (Pi 2019-02-10): Show the user's activity stream here, once we have that. #} 38 | 39 | {# get all the questions that the user has asked #} 40 |
41 | {% for question in user_object.question_set.all %} 42 | {% include 'includes/question_card_brief.html' with question=question only %} 43 | {% endfor %} 44 |
45 | 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /src/buza/settings_docker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings module for buza-website using django-environ. 3 | The settings are used by the docker file 4 | """ 5 | import os 6 | 7 | import environ 8 | 9 | from buza.settings_base import * # noqa: F401 10 | 11 | 12 | env = environ.Env() 13 | 14 | # Obtain a base instance directory. 15 | 16 | DEBUG = False 17 | TEMPLATE_DEBUG = False 18 | COMPRESS_OFFLINE = True 19 | 20 | SECRET_KEY = env('DJANGO_SECRET_KEY') 21 | SOCIAL_AUTH_RAISE_EXCEPTIONS = os.environ.get('SOCIAL_AUTH_RAISE_EXCEPTIONS') 22 | SOCIAL_AUTH_FACEBOOK_API_VERSION = '2.11' 23 | LOGIN_ERROR_URL = '/' 24 | LOGIN_REDIRECT_URL = os.environ.get('redirect_uri') 25 | 26 | SOCIAL_AUTH_LOGIN_REDIRECT_URL = os.environ.get('SOCIAL_AUTH_LOGIN_REDIRECT_URL') 27 | SOCIAL_AUTH_SANITIZE_REDIRECTS = os.environ.get('SOCIAL_AUTH_SANITIZE_REDIRECTS') 28 | USE_X_FORWARDED_HOST = os.environ.get('USE_X_FORWARDED_HOST') 29 | ROOT_URL = os.environ.get('ROOT_URL') 30 | # social auth keys 31 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get("GoogleKey", "none") 32 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get("GoogleSecret", "none") 33 | 34 | SOCIAL_AUTH_FACEBOOK_KEY = os.environ.get("FbKey", "none") 35 | SOCIAL_AUTH_FACEBOOK_SECRET = os.environ.get("FbSecret", "none") 36 | 37 | ALLOWED_HOSTS = ALLOWED_HOSTS = ['127.0.0.1', 'localhost', 'buza.co.za'] 38 | 39 | # settings.py 40 | DATABASES = { 41 | 'default': { 42 | "ENGINE": "django.db.backends.postgresql", 43 | "NAME": os.environ.get("DB_NAME", "buza_answers"), 44 | "USER": os.environ.get("DB_USER", "postgres"), 45 | "PASSWORD": os.environ.get("DB_PASSWORD", ""), 46 | "HOST": os.environ.get("DB_HOST", "127.0.0.1"), 47 | "PORT": os.environ.get("DB_PORT", "5432"), 48 | "CONN_MAX_AGE": 600, 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /src/buza/migrations/0004_remove_question_topics.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-20 11:16 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('buza', '0003_question_grade'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='questiontopic', 16 | name='content_type', 17 | ), 18 | migrations.RemoveField( 19 | model_name='questiontopic', 20 | name='tag', 21 | ), 22 | migrations.RemoveField( 23 | model_name='question', 24 | name='topics', 25 | ), 26 | migrations.AlterField( 27 | model_name='question', 28 | name='body', 29 | field=models.TextField(blank=True, help_text='Give a detailed description of your question', verbose_name='Question Description'), 30 | ), 31 | migrations.AlterField( 32 | model_name='question', 33 | name='grade', 34 | field=models.IntegerField(help_text='Which grade it this question most relevant for? By default this will be the grade that you are in.', validators=[django.core.validators.MinValueValidator(7), django.core.validators.MaxValueValidator(12)]), 35 | ), 36 | migrations.AlterField( 37 | model_name='question', 38 | name='title', 39 | field=models.CharField(help_text='Write a short sentence summarising your question', max_length=1024, verbose_name='Question Summary'), 40 | ), 41 | migrations.DeleteModel( 42 | name='QuestionTopic', 43 | ), 44 | migrations.DeleteModel( 45 | name='Topic', 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | 4 | matrix: 5 | include: 6 | - { python: '3.6', env: TOXENV=py36 } 7 | 8 | # XXX (2018-08-10): Work around Travis shenanigans for Python 3.7: 9 | # https://github.com/travis-ci/travis-ci/issues/9815 10 | - { python: '3.7', env: TOXENV=py37, dist: xenial, sudo: true } 11 | 12 | # Codecov reporting: 13 | - { python: '3.6', env: TOXENV=py36-codecov } 14 | 15 | install: 16 | - pip install tox 17 | 18 | script: 19 | - tox 20 | 21 | env: 22 | global: 23 | - IMAGE_NAME=buzaproject/buza-answers 24 | - REGISTRY_USER=sewagodimo 25 | - secure: EHpFX2eS3iVEUyClYW8Bq44bi8YMyILzMvNg9dikKfQ/+Ua7r1diRmtLL7TLnXMJM2QB0nDik1r2b0DhIWM8aTEjLu1Eo18MUgj4c27NSiF3kLO1DnXD2pPs18QZcTa2L8Hr5mBLjBuhDSKuscHGMu45xcfwtlkx05LIcT/d8kt7IXZToFo1bmjR4n03MY6QEf7i6/FIUgDxfV4v9/acz1BJJj/EPyLp8GalIQbf5E5lOppWooYrfCsks2cI7GPjZWRDNRMxni7nOmXKJLwXHz2mjcfarBN+3/cg2GOq8/Wul7T0CtFIBATLCWZ+LV/EqFAeFBKXN59U2uylmfQK4fF2AAsId7tZ9ZTCfnbiUCV6fmEbAafdg9GztdmVsviNBo3J125lQfyU/BFLcFz9dfoLj+yxLLFrAP3ikkTBNSOr0WVyZ9dwqH6ezOeqs++Wop09tKiViIRFkPelDJKUeuuA8sgAe5TxTF5hP3cnlOyx+vrPzeQLTC5oYN9zJmnSwaGu5/+7zqDtTsKwRizVNexV35AmoRUKDKBDMAtQLyi4dSAlJ4euumh4yEhCWMfmjf4pWv0qmBHB7Vx9/I/5efFo76C+EHXHodskaltHpMFq7wdejEq9K+RukV2Gg0AclVRqxaZ79//XV1fjxwxNEGr3eRP1Ll92r86EI1eGGvk= 26 | after_success: 27 | - coveralls 28 | 29 | jobs: 30 | include: 31 | - stage: test 32 | python: '3.6' 33 | - stage: docker 34 | services: 35 | - docker 36 | dist: xenial 37 | python: '3.7' 38 | before_script: 39 | - docker pull "$IMAGE_NAME" || true 40 | script: 41 | - docker build --pull --cache-from "$IMAGE_NAME" -t "$IMAGE_NAME" . 42 | before_deploy: 43 | - docker login -u "$REGISTRY_USER" -p "$REGISTRY_PASS" 44 | - pip install docker-ci-deploy==0.3.0 45 | deploy: 46 | provider: script 47 | script: dcd -V $(git rev-parse --short HEAD) -L "$IMAGE_NAME" 48 | on: 49 | branch: develop 50 | -------------------------------------------------------------------------------- /src/buza/urls.py: -------------------------------------------------------------------------------- 1 | import django.contrib.auth.urls 2 | from django.conf.urls import url 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | 6 | from buza import views 7 | 8 | 9 | urlpatterns = [ 10 | 11 | # social-auth-app-django 12 | url(r'^login/', include('social_django.urls', namespace='social')), 13 | 14 | url(r'about/privacy/', views.PrivacyPolicyView.as_view(), name='privacy-policy'), 15 | url(r'about/tos/', views.TermsOfService.as_view(), name='terms-of-service'), 16 | # Django auth 17 | path('auth/', include(django.contrib.auth.urls)), 18 | 19 | path('', views.HomePageView.as_view(), name='home'), 20 | 21 | # user related paths 22 | url(r'^register/$', views.register, name='register'), 23 | path(r'users//update/', views.UserUpdate.as_view(), name='user-update'), 24 | path(r'users//', views.UserDetail.as_view(), name='user-detail'), 25 | 26 | # subject related paths 27 | path('subjects//', views.SubjectDetail.as_view(), name='subject-detail'), 28 | 29 | # question related paths 30 | path('questions/', views.QuestionList.as_view(), name='question-list'), 31 | path('questions//', views.QuestionDetail.as_view(), name='question-detail'), 32 | path( 33 | 'questions//ask/', 34 | views.QuestionCreate.as_view(), 35 | name='question-create', 36 | ), 37 | path( 38 | 'questions//edit/', 39 | views.QuestionUpdate.as_view(), 40 | name='question-update', 41 | ), 42 | 43 | # answer related paths 44 | path( 45 | 'answers//edit/', 46 | views.AnswerUpdate.as_view(), 47 | name='answer-update', 48 | ), 49 | path( 50 | 'questions//answer/', 51 | views.AnswerCreate.as_view(), 52 | name='answer-create', 53 | ), 54 | # Django admin 55 | url(r'^admin/', admin.site.urls), 56 | 57 | ] 58 | -------------------------------------------------------------------------------- /src/buza/templates/includes/subject_cards.html: -------------------------------------------------------------------------------- 1 | {% if user.is_authenticated %} 2 | {% if subject in user.subjects.all %} 3 | 15 | {% else %} 16 | 27 | {% endif %} 28 | {% else %} 29 | 34 | {% endif %} 35 | -------------------------------------------------------------------------------- /src/buza/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {# TODO (Pi 2018-08-27): Minimise this to just the Bootstrap bits we actually use. #} 10 | 11 | 12 | 13 | 14 | 15 | {% block head_extra %}{% endblock %} 16 | 17 | {% block title %}Buza{% endblock %} 18 | 19 | 20 | 47 | 48 | {% if messages %} 49 |
    50 | {% for message in messages %} 51 |
  • 52 | {{ message }} 53 |
  • 54 | {% endfor %} 55 |
56 | {% endif %} 57 | 58 |
59 |
60 |
61 | {% block content %}{% endblock %} 62 |
63 |
64 |
65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/buza/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from buza import models 6 | 7 | 8 | @admin.register(models.User) 9 | class UserAdmin(DjangoUserAdmin): 10 | """ 11 | Extend the base Django UserAdmin with support for some Buza fields. 12 | """ 13 | 14 | date_hierarchy = 'date_joined' 15 | ordering = ['-date_joined'] 16 | list_display = list(DjangoUserAdmin.list_display) + ['grade', 'date_joined'] 17 | list_filter = list(DjangoUserAdmin.list_filter) + ['grade'] 18 | 19 | fieldsets = list(DjangoUserAdmin.fieldsets[:1]) + [ 20 | (_('Buza fields'), {'fields': [ 21 | 'phone', 22 | 'grade', 23 | 'photo', 24 | 'bio', 25 | ]}), 26 | ] + list(DjangoUserAdmin.fieldsets[1:]) 27 | 28 | 29 | class AnswerInline(admin.TabularInline): 30 | extra = 0 31 | model = models.Answer 32 | 33 | fields = ['author', 'body'] 34 | raw_id_fields = ['author'] 35 | 36 | 37 | @admin.register(models.Question) 38 | class QuestionAdmin(admin.ModelAdmin): 39 | date_hierarchy = 'created' 40 | ordering = ['-created'] 41 | list_display = ['title', 'author', 'created', 'subject'] 42 | search_fields = ['title', 'author__username', 'subject__title'] 43 | list_filter = ['subject'] 44 | 45 | raw_id_fields = ['author', 'subject'] 46 | readonly_fields = ['created', 'modified'] 47 | 48 | inlines = [AnswerInline] 49 | 50 | 51 | @admin.register(models.Answer) 52 | class AnswerAdmin(admin.ModelAdmin): 53 | date_hierarchy = 'created' 54 | ordering = ['-created'] 55 | list_display = ['body', 'author', 'question', 'created'] 56 | search_fields = [ 57 | 'body', 58 | 'author__username', 59 | 'question__author__username', 60 | 'question__title', 61 | ] 62 | 63 | raw_id_fields = ['author', 'question'] 64 | readonly_fields = ['created', 'modified'] 65 | 66 | 67 | @admin.register(models.Subject) 68 | class SubjectAdmin(admin.ModelAdmin): 69 | ordering = ['-title'] 70 | list_display = ['title', 'description'] 71 | search_fields = ['title', 'description'] 72 | -------------------------------------------------------------------------------- /src/buza/static/buza/css/questions.css: -------------------------------------------------------------------------------- 1 | /* Styles for questions. */ 2 | 3 | /* Block: Question */ 4 | .question__card { 5 | background-color: white; 6 | padding: 1em 2em; 7 | /* Spacing between questions: */ 8 | margin-top: 1em; 9 | margin-bottom: 1em; 10 | /* Shadow */ 11 | border: 1px solid rgba(0,0,0,.125); 12 | border-radius: 0.15em; 13 | max-width: 45em; 14 | } 15 | 16 | .question__card:hover { 17 | box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); 18 | } 19 | 20 | .question__title { 21 | font-weight: bold; 22 | font-size: 1.2rem 23 | } 24 | .question__attribution { 25 | opacity: 0.5; 26 | font-size: smaller; 27 | font-weight: bold; 28 | } 29 | 30 | .question__answers_heading { 31 | font-weight: bold; 32 | text-align: center; 33 | color: var(--buza-purple); 34 | } 35 | 36 | .question__card-header{ 37 | border-bottom: 1px solid rgba(0, 0, 0, 0.125); 38 | } 39 | 40 | /* XXX (Pi 2018-08-27): Temporary btn-primary override for our Buza green, until we do this better. */ 41 | .btn-buza-green { 42 | background-color: var(--buza-green); 43 | border-color: var(--buza-green); 44 | border-radius: 1em; 45 | } 46 | 47 | .btn-buza-green:hover{ 48 | background-color: var(--buza-green); 49 | border-color: var(--buza-light-green); 50 | } 51 | 52 | .question__add_answer { 53 | background-color: var(--buza-purple); 54 | border-radius: 12px; 55 | padding: 1px 4%; 56 | text-align: center; 57 | color: white; 58 | } 59 | 60 | .question__edit_answer { 61 | background-color:var(--buza-purple); 62 | border-radius: 12px; 63 | padding: 1px 2%; 64 | text-align: center; 65 | color: white; 66 | } 67 | 68 | /* XXX (Pi): Temporarily hack around these textareas being too wide on small views. */ 69 | textarea[name=body] { 70 | max-width: 100%; 71 | } 72 | 73 | /* TODO (Pi 2018-08-27): Target the Bootstrap form-control class directly, for now. Customise this more properly later. */ 74 | .form-control { 75 | border: 2px solid var(--buza-light-blue); 76 | } 77 | 78 | 79 | .dot { 80 | height: 10px; 81 | width: 10px; 82 | background-color: var(--buza-light-purple); 83 | border-radius: 50%; 84 | display: inline-block; 85 | } 86 | -------------------------------------------------------------------------------- /tests/test_settings_env.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | from typing import Any, Dict 4 | from unittest import mock 5 | 6 | from buza import settings_base 7 | 8 | 9 | def _get_settings(settings_module: object) -> Dict[str, Any]: 10 | """ 11 | Helper: Render a Django settings module as a dictionary. 12 | """ 13 | _vars: Dict[str, Any] = vars(settings_module) 14 | return { 15 | name: value for (name, value) in _vars.items() 16 | if name.isupper() 17 | } 18 | 19 | 20 | def test_settings_env() -> None: 21 | """ 22 | Verify that `buza.settings_env` renders as expected. 23 | """ 24 | # Patch the environment with test variables. 25 | test_environ = { 26 | 'BASE_DIR': '/base', 27 | 'DJANGO_SECRET_KEY': 'secret key', 28 | } 29 | with mock.patch.dict(os.environ, test_environ, clear=True): 30 | from buza import settings_env 31 | importlib.reload(settings_env) # Make sure this gets reloaded. 32 | 33 | expected_settings = { 34 | # Use buza.settings_base as a base, so we only list the additions here. 35 | **_get_settings(settings_base), 36 | 'DEBUG': False, 37 | 'SECRET_KEY': 'secret key', 38 | 'DATABASES': { 39 | 'default': { 40 | 'ENGINE': 'django.db.backends.sqlite3', 41 | 'HOST': '', 42 | 'NAME': '/base/buza.sqlite3', 43 | 'PASSWORD': '', 44 | 'PORT': '', 45 | 'USER': '', 46 | }, 47 | }, 48 | 'STATIC_ROOT': '/base/static_root', 49 | 'STATIC_URL': '/static/', 50 | 'MEDIA_ROOT': '/base/media_root', 51 | 'MEDIA_URL': '/media/', 52 | 'LANGUAGE_CODE': 'en-ZA', 53 | 'TIME_ZONE': 'Africa/Johannesburg', 54 | 'LOGIN_ERROR_URL': '/', 55 | 'SOCIAL_AUTH_FACEBOOK_API_VERSION': '2.8', 56 | 'SOCIAL_AUTH_FACEBOOK_KEY': 'none', 57 | 'SOCIAL_AUTH_FACEBOOK_SECRET': 'none', 58 | 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'none', 59 | 'SOCIAL_AUTH_RAISE_EXCEPTIONS': False, 60 | 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': 'none', 61 | 'ALLOWED_HOSTS': ['localhost'], 62 | } 63 | assert expected_settings == _get_settings(settings_env) 64 | -------------------------------------------------------------------------------- /src/buza/migrations/0002_add_question_topics.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-08-19 13:42 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import taggit.managers 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('contenttypes', '0002_remove_content_type_name'), 12 | ('buza', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='QuestionTopic', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('object_id', models.IntegerField(db_index=True, verbose_name='Object id')), 21 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='buza_questiontopic_tagged_items', to='contenttypes.ContentType', verbose_name='Content type')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='Topic', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('name', models.CharField(max_length=100, unique=True, verbose_name='Name')), 32 | ('slug', models.SlugField(max_length=100, unique=True, verbose_name='Slug')), 33 | ('description', models.TextField()), 34 | ], 35 | options={ 36 | 'verbose_name': 'Topic', 37 | 'verbose_name_plural': 'Topics', 38 | }, 39 | ), 40 | migrations.AddField( 41 | model_name='questiontopic', 42 | name='tag', 43 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='question_topics', to='buza.Topic'), 44 | ), 45 | migrations.AddField( 46 | model_name='question', 47 | name='topics', 48 | field=taggit.managers.TaggableManager(help_text='List all the relevant topics for this question. \nExample: Triangles, Equations, Photosynthesis.', through='buza.QuestionTopic', to='buza.Topic', verbose_name='Tags'), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /src/buza/static/buza/css/login.css: -------------------------------------------------------------------------------- 1 | .header { grid-area: header; 2 | grid-column: 1 ; /* start at the leftmost */ 3 | grid-column-end: 3; /* cover the whole row */ 4 | grid-row: 1; /* place at the top, first row*/ 5 | text-align: center; 6 | color: var(--buza-blue); 7 | } 8 | .socials { 9 | grid-area: socials; 10 | grid-column: 1 ; /* start left*/ 11 | grid-row: 2; /* on the second row*/ 12 | padding-left: 10%; 13 | } 14 | .login { 15 | grid-area: login; 16 | grid-column: 2 ; /* place rightmost*/ 17 | grid-row: 2; 18 | padding-left: 10%; 19 | padding-right: 3%; 20 | border-left: 2px solid var(--buza-blue); 21 | } 22 | .footer { 23 | grid-area: footer; 24 | grid-column: 1 ; 25 | grid-column-end: 3; 26 | grid-row: 3} 27 | 28 | .homepage { 29 | padding-top: 10%; 30 | margin-left: 25%; 31 | } 32 | .login-container { 33 | display: grid; 34 | grid-gap: 3%; 35 | background: white; 36 | position: absolute; 37 | width: 50%; 38 | padding-bottom: 5%; 39 | font-size: 13px; 40 | box-shadow: 1px 1px 5px 5px lightgrey; 41 | color: grey; 42 | } 43 | 44 | @media all and (max-width: 1000px) { 45 | .homepage { 46 | margin-left: 0%; 47 | } 48 | .login-container { 49 | width: 100%; 50 | right:0; 51 | font-size: 1em; 52 | box-shadow: 0px 0px 0px 0px white; 53 | } 54 | .slogan-text { 55 | 56 | font-size: 1em; 57 | } 58 | } 59 | @media all and (max-width: 720px) { 60 | .login-container { 61 | display: flex; 62 | flex-direction: column; 63 | width: 100%; 64 | right:0; 65 | box-shadow: 0px 0px 0px 0px white; 66 | margin-left: 5%; 67 | 68 | } 69 | .homepage { 70 | margin-left: 0%; 71 | } 72 | } 73 | 74 | /* SOCIAL LOGIN BUTTONS */ 75 | .social-button { 76 | display: inline-block; 77 | border-radius: 4px; 78 | border: none; 79 | color: #FFFFFF; 80 | text-align: center; 81 | font-size: 15px; 82 | padding: 5px; 83 | width: 200px; 84 | transition: all 0.5s; 85 | cursor: pointer; 86 | margin: 5px; 87 | } 88 | .facebook{ 89 | background: #3B5998; 90 | } 91 | 92 | .google { 93 | background: #EA4335; 94 | } 95 | 96 | /************** SPEECH BUBBLES ******************/ -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from buza import models 4 | 5 | 6 | class TestUser(TestCase): 7 | 8 | def test_defaults(self) -> None: 9 | user = models.User.objects.create() 10 | assert { 11 | 'bio': None, 12 | 'date_joined': user.date_joined, 13 | 'email': '', 14 | 'first_name': '', 15 | 'grade': 7, 16 | 'id': user.pk, 17 | 'is_active': True, 18 | 'is_staff': False, 19 | 'is_superuser': False, 20 | 'last_login': None, 21 | 'last_name': '', 22 | 'password': '', 23 | 'phone': '', 24 | 'photo': '', 25 | 'school': None, 26 | 'school_address': None, 27 | 'username': '', 28 | } == models.User.objects.filter(pk=user.pk).values().get() 29 | 30 | def test_repr(self) -> None: 31 | assert '' == repr(models.User(username='test')) 32 | 33 | 34 | class TestSubject(TestCase): 35 | 36 | def test_repr(self) -> None: 37 | subject = models.Subject( 38 | title='Biology', 39 | description='The study of life', 40 | short_title='Bio', 41 | ) 42 | assert '' == repr(subject) 43 | 44 | 45 | class TestQuestion(TestCase): 46 | 47 | def test_repr(self) -> None: 48 | user = models.User(username='tester') 49 | question = models.Question( 50 | author=user, 51 | title='Example question?', 52 | ) 53 | assert '' == repr(question) 54 | 55 | 56 | class TestAnswer(TestCase): 57 | 58 | def test_repr(self) -> None: 59 | user = models.User.objects.create(username='tester') 60 | self.subject: models.Subject = models.Subject.objects.create(title="maths") 61 | question: models.Question = models.Question.objects.create( 62 | author=user, 63 | title='Example question?', 64 | subject=self.subject, 65 | grade=7, 66 | ) 67 | answer: models.Answer = question.answer_set.create( 68 | author=user, 69 | body='Example Answer.', 70 | ) 71 | expected = f'' 72 | assert expected == repr(answer) 73 | -------------------------------------------------------------------------------- /src/buza/templates/buza/question_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load humanize %} 4 | 5 | {% block head_extra %} 6 | {{ block.super }} 7 | 8 | {% endblock %} 9 | 10 | {% block title %}{{ question.title }} - {{ block.super }}{% endblock %} 11 | 12 | {% block content %} 13 | {% include 'includes/question_card_full.html' with question=question user=user only %} 14 |
15 | {% with answers=question.answer_set.all %} {# TODO: Ordering #} 16 | {% if answers %} 17 |
Answers
18 |
19 | 20 | 21 | 22 |
23 | {% for answer in question.answer_set.all %} 24 |
25 |
26 | 27 | @{{ answer.author.username }} 28 |

29 | {{ answer.modified | naturaltime}}

30 |
31 |
32 | {{ answer.body}} 33 | {% if user == answer.author %} 34 |

35 | 36 | Edit answer 37 | 38 |

39 | {% endif %} 40 |
41 | {% endfor %} 42 | {% else %} 43 |

No answers yet

44 |
45 | 46 | 47 | 48 |
49 | {% endif %} 50 | {% endwith %} 51 | 52 | 57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /src/buza/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {# TODO (Pi 2018-08-27): Minimise this to just the Bootstrap bits we actually use. #} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% block title %}Buza Login{% endblock %} 18 | 19 | 20 |
21 | 70 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Buza 2 | ==== 3 | 4 | This is the code for Buza mobi site 5 | 6 | Getting started 7 | --------------- 8 | 9 | With Vagrant 10 | ^^^^^^^^^^^^ 11 | 12 | The easiest way to get a development instance of Buza up and running is to use `Vagrant`_. 13 | 14 | .. _`Vagrant`: https://www.vagrantup.com/ 15 | 16 | After installing Vagrant, run the following to provision a Buza virtual machine:: 17 | 18 | vagrant up 19 | 20 | (This will take a while the first time you run it, but will be faster on subsequent runs.) 21 | 22 | To run the Django development server, you can execute the following command:: 23 | 24 | vagrant ssh -c 'cd /vagrant && pipenv run django-admin runserver 0.0.0.0:8000' 25 | 26 | You can also log into the virtual machine and activate the project, 27 | in order to run other Django development commands. For example:: 28 | 29 | $ vagrant ssh 30 | vagrant@ubuntu-bionic:~$ cd /vagrant 31 | vagrant@ubuntu-bionic:/vagrant$ pipenv shell 32 | Loading .env environment variables... 33 | Launching subshell in virtual environment… 34 | (vagrant-gKDsaKU3) vagrant@ubuntu-bionic:/vagrant$ django-admin check 35 | System check identified no issues (0 silenced). 36 | (vagrant-gKDsaKU3) vagrant@ubuntu-bionic:/vagrant$ 37 | 38 | When you're finished working, you can stop the Vagrant virtual machine by running ``vagrant halt``. 39 | Running ``vagrant up`` again will restart it. 40 | 41 | To destroy the virtual machine completely, run ``vagrant destroy``. 42 | 43 | You can also create the docker image for the project to run it:: 44 | 45 | $ docker-compose up 46 | 47 | 48 | With Pipenv 49 | ^^^^^^^^^^^ 50 | 51 | To set up a conventional Python development environment, 52 | make sure you have the following tools installed: 53 | 54 | * Pipenv_ 55 | * Yarn_ 56 | 57 | .. _Pipenv: https://docs.pipenv.org/install/#installing-pipenv 58 | .. _Yarn: https://yarnpkg.com/lang/en/docs/install/ 59 | 60 | Django requires the ``DJANGO_SETTINGS_MODULE`` environment variable to run. 61 | To set this and get started, copy the ``env.example`` file to ``.env``:: 62 | 63 | $ cp .env.example .env 64 | 65 | (Pipenv will `automatically load`_ the environment variables defined in this ``.env`` file.) 66 | 67 | .. _`automatically load`: https://docs.pipenv.org/advanced/#automatic-loading-of-env 68 | 69 | Fetch the Yarn dependencies:: 70 | 71 | $ yarn 72 | 73 | Install the Pipenv dependencies, and activate the environment:: 74 | 75 | $ pipenv install --dev 76 | $ pipenv shell 77 | 78 | Initialise the database, and run the Django development server:: 79 | 80 | $ django-admin migrate 81 | $ django-admin createsuperuser 82 | $ django-admin runserver 83 | 84 | 85 | Running checks and tests 86 | ------------------------ 87 | 88 | To run all the static checks and tests, invoke Tox:: 89 | 90 | $ tox 91 | 92 | To run the checks and tests individually, see the "commands" section of ``tox.ini``. 93 | 94 | 95 | Git pre-commit hook 96 | ------------------- 97 | 98 | To run our main quick checks before each commit, add the following to ``.git/hooks/pre-commit``:: 99 | 100 | #!/bin/sh -e 101 | 102 | mypy -i src tests 103 | flake8 104 | isort --check-only 105 | 106 | -------------------------------------------------------------------------------- /src/buza/static/buza/css/subjects.css: -------------------------------------------------------------------------------- 1 | /* Styles for Subjects. */ 2 | 3 | /* Block: Subjects */ 4 | 5 | subject-nav_bar{ 6 | background-color: silver; 7 | font-weight: bold; 8 | border-radius: 10%; 9 | } 10 | 11 | .subject_heading { 12 | font-weight: bold; 13 | text-align: left; 14 | color:#007bff; 15 | } 16 | 17 | .subject__deck { 18 | padding: 1em 2em; 19 | text-align: center; 20 | display: inline-block; 21 | width: 20em; 22 | } 23 | /* The Subject Following Card*/ 24 | .subject__card_following { 25 | padding: 0.5em 1em; 26 | background: white; 27 | } 28 | 29 | .subject__card_following:hover { 30 | box-shadow: 12px 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); 31 | } 32 | 33 | .subject__card_focus{ 34 | background: var(--buza-light-green); 35 | padding-bottom: 1em; 36 | padding-top: 1em; 37 | box-shadow: 0 16px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); 38 | } 39 | 40 | .subject__title_following { 41 | text-align: left; 42 | color: var(--buza-light-green); 43 | font-weight:bold; 44 | } 45 | 46 | .subject__title_focus{ 47 | color: white; 48 | background: var(--buza-light-green); 49 | font-weight:bold; 50 | text-align: left; 51 | } 52 | 53 | .subject__title_following:hover { 54 | font-weight: bold; 55 | text-decoration: None; 56 | } 57 | /* The Subject Follow Card*/ 58 | .subject__card_follow { 59 | padding: 0.5em 1em; 60 | 61 | } 62 | .subject__card_follow:hover { 63 | box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); 64 | } 65 | .subject__title_follow { 66 | text-align: left; 67 | font-weight:bold; 68 | color: var(--buza-grey); 69 | } 70 | .subject__title_follow:hover { 71 | text-decoration: None; 72 | } 73 | 74 | /* The Follow/Following buttons*/ 75 | .subject__buttons { 76 | border: 1px solid var(--buza-green); 77 | padding: 0.2em 0.2em; 78 | border-radius: 50%; 79 | font-weight: bold; 80 | text-align: right; 81 | } 82 | 83 | .subject__buttons-follow{ 84 | background-color: white; 85 | color: var(--buza-grey); 86 | text-align: right; 87 | 88 | } 89 | 90 | .subject__buttons-following{ 91 | border: 1px solid var(--buza-light-green); 92 | background: var(--buza-light-green); 93 | color: white; 94 | } 95 | 96 | .subject__subjects-nav{ 97 | padding: 1% 4%; 98 | text-align: center; 99 | text-decoration: none; 100 | display: inline-block; 101 | font-size: 16px; 102 | border: 2px solid var(--buza-green); /* Green */ 103 | } 104 | 105 | .subject__subjects-nav-view{ 106 | background: var(--buza-green); /* Green */ 107 | } 108 | 109 | .subject__subjects-nav-view, .subject__subjects-nav-view:hover{ 110 | color: white; 111 | } 112 | 113 | .subject__subjects-nav-hidden{ 114 | background-color:white; 115 | } 116 | 117 | .subject__subjects-nav-hidden, .subject__subjects-nav-hidden:hover{ 118 | color: #32CD32; /* Green */ 119 | } 120 | /* Subject Detail flext box */ 121 | .subject_detail-container { 122 | display: flex; 123 | align-items: stretch; 124 | } 125 | .btn__new_question{ 126 | padding-left: 15em; 127 | } 128 | 129 | .subject__question_list{ 130 | flex-grow: 3; 131 | } 132 | -------------------------------------------------------------------------------- /src/buza/settings_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base Django settings for a buza-website instance. 3 | """ 4 | import os 5 | from pathlib import Path 6 | 7 | import environ 8 | from django.urls import reverse_lazy 9 | 10 | 11 | env = environ.Env() 12 | 13 | # Assume we're running from a Git checkout directory. 14 | checkout_dir: Path = Path(__file__).parent.parent.parent 15 | if checkout_dir.joinpath('.git').exists(): 16 | assert checkout_dir.joinpath('.git').exists(), checkout_dir 17 | 18 | ROOT_URLCONF = 'buza.urls' 19 | 20 | INSTALLED_APPS = [ 21 | # Buza 22 | 'buza', 23 | 24 | # Third-party apps 25 | 'crispy_forms', 26 | 'taggit', 27 | 28 | # Django apps 29 | 'django.contrib.admin', 30 | 'django.contrib.auth', 31 | 'django.contrib.contenttypes', 32 | 'django.contrib.sessions', 33 | 'django.contrib.messages', 34 | 'django.contrib.staticfiles', 35 | 'social_django', 36 | 'django.contrib.humanize', 37 | ] 38 | 39 | MIDDLEWARE = [ 40 | 'social_django.middleware.SocialAuthExceptionMiddleware', 41 | 'django.middleware.security.SecurityMiddleware', 42 | 'django.contrib.sessions.middleware.SessionMiddleware', 43 | 'django.middleware.common.CommonMiddleware', 44 | 'django.middleware.csrf.CsrfViewMiddleware', 45 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 46 | 'django.contrib.messages.middleware.MessageMiddleware', 47 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 48 | ] 49 | 50 | TEMPLATES = [{ 51 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 52 | 'APP_DIRS': True, 53 | 'OPTIONS': { 54 | 'context_processors': [ 55 | 'django.contrib.auth.context_processors.auth', 56 | 'django.contrib.messages.context_processors.messages', 57 | ], 58 | }, 59 | }] 60 | 61 | AUTHENTICATION_BACKENDS = [ 62 | 'social_core.backends.facebook.FacebookOAuth2', 63 | 'social_core.backends.google.GoogleOAuth2', 64 | 'social_core.backends.google.GoogleOAuth', 65 | 'social_core.backends.google.GooglePlusAuth', 66 | 'django.contrib.auth.backends.ModelBackend', 67 | ] 68 | # Internationalization 69 | USE_I18N = True 70 | USE_L10N = True 71 | USE_TZ = True 72 | 73 | 74 | # django.contrib.auth 75 | AUTH_USER_MODEL = 'buza.User' 76 | SOCIAL_AUTH_LOGIN_REDIRECT_URL = 'home' 77 | LOGIN_URL = reverse_lazy('login') 78 | LOGOUT_URL = reverse_lazy('logout') 79 | LOGIN_REDIRECT_URL = reverse_lazy('home') 80 | 81 | # django-crispy-forms 82 | CRISPY_TEMPLATE_PACK = 'bootstrap4' 83 | 84 | 85 | STATICFILES_DIRS = [ 86 | # Path to Yarn's packages 87 | str(checkout_dir.joinpath('node_modules')), 88 | ] 89 | 90 | # Include the local host by default for development. 91 | ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] 92 | 93 | BASE_DIR = os.environ.get('BASE_DIR') or str(checkout_dir.joinpath('buza-instance')) 94 | 95 | SECRET_KEY = 'secret-key' 96 | 97 | MEDIA_ROOT = os.environ.get("MEDIA_ROOT", "media") 98 | 99 | STATIC_ROOT = os.environ.get("STATIC_ROOT", "static") 100 | STATIC_URL = env('DJANGO_STATIC_URL', default='/static/') 101 | 102 | # Internationalization 103 | LANGUAGE_CODE = env('DJANGO_LANGUAGE_CODE', default='en-ZA') 104 | TIME_ZONE = env('DJANGO_TIME_ZONE', default='Africa/Johannesburg') 105 | -------------------------------------------------------------------------------- /src/buza/models.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Union 3 | 4 | from django.contrib.auth.models import AbstractUser, AnonymousUser 5 | from django.core.validators import MaxValueValidator, MinValueValidator 6 | from django.db import models 7 | from django.utils.translation import ugettext_lazy as _ 8 | 9 | 10 | # Shortcuts: 11 | _CharField = partial(models.CharField, max_length=1024) 12 | 13 | 14 | class TimestampedModel(models.Model): 15 | """ 16 | Base class with `created` and `modified` fields. 17 | """ 18 | created = models.DateTimeField(auto_now_add=True, db_index=True) 19 | modified = models.DateTimeField(auto_now=True, db_index=True) 20 | 21 | class Meta: 22 | abstract = True 23 | 24 | 25 | class Subject(models.Model): 26 | title = _CharField() 27 | short_title = models.TextField() 28 | description = models.TextField() 29 | 30 | def __str__(self) -> str: 31 | return str(self.title) 32 | 33 | 34 | class User(AbstractUser): 35 | 36 | # Authentication fields 37 | phone = models.CharField( 38 | _('phone number'), 39 | blank=True, 40 | max_length=11, 41 | ) 42 | 43 | # School fields 44 | school = models.CharField(_('school name'), blank=True, null=True, max_length=100) 45 | school_address = models.CharField( 46 | _('school address'), 47 | blank=True, null=True, 48 | max_length=300, 49 | ) 50 | grade = models.IntegerField(blank=True, null=True, default=7) 51 | 52 | # Personal fields 53 | photo = models.ImageField(_('profile photo'), 54 | upload_to='users/%Y/%m/%d', 55 | blank=True) 56 | bio = models.CharField(blank=True, null=True, max_length=250) 57 | 58 | subjects = models.ManyToManyField(Subject) 59 | 60 | def __str__(self) -> str: 61 | return str(self.username) 62 | 63 | 64 | #: Helper type for Django request users: either anonymous or signed-in. 65 | RequestUser = Union[AnonymousUser, User] 66 | 67 | 68 | class Question(TimestampedModel, models.Model): 69 | 70 | author = models.ForeignKey(User, on_delete=models.PROTECT) 71 | 72 | title = _CharField( 73 | verbose_name='Question Summary', 74 | help_text='Write a short sentence summarising your question', 75 | ) 76 | body = models.TextField( 77 | blank=True, 78 | verbose_name='Question Description', 79 | help_text='Give a detailed description of your question') 80 | subject = models.ForeignKey(Subject, on_delete=models.PROTECT) 81 | grade = models.IntegerField( 82 | validators=[MinValueValidator(7), MaxValueValidator(12)], 83 | help_text="Which grade it this question most relevant for? " 84 | "By default this will be the grade that you are in.", 85 | ) 86 | 87 | def __str__(self) -> str: 88 | return f'By {self.author}: {self.title}' 89 | 90 | 91 | class Answer(TimestampedModel, models.Model): 92 | 93 | author = models.ForeignKey(User, on_delete=models.PROTECT) 94 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 95 | 96 | body = models.TextField() 97 | 98 | def __str__(self) -> str: 99 | question: Question = self.question 100 | return f'By {self.author} to question {question.pk}: {question.title}' 101 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # All Vagrant configuration is done below. The "2" in Vagrant.configure 5 | # configures the configuration version (we support older styles for 6 | # backwards compatibility). Please don't change it unless you know what 7 | # you're doing. 8 | Vagrant.configure("2") do |config| 9 | # The most common configuration options are documented and commented below. 10 | # For a complete reference, please see the online documentation at 11 | # https://docs.vagrantup.com. 12 | 13 | # Every Vagrant development environment requires a box. You can search for 14 | # boxes at https://vagrantcloud.com/search. 15 | config.vm.box = "ubuntu/bionic64" 16 | 17 | # Disable automatic box update checking. If you disable this, then 18 | # boxes will only be checked for updates when the user runs 19 | # `vagrant box outdated`. This is not recommended. 20 | # config.vm.box_check_update = false 21 | 22 | # Create a forwarded port mapping which allows access to a specific port 23 | # within the machine from a port on the host machine. In the example below, 24 | # accessing "localhost:8080" will access port 80 on the guest machine. 25 | # NOTE: This will enable public access to the opened port 26 | # config.vm.network "forwarded_port", guest: 80, host: 8080 27 | 28 | # Create a forwarded port mapping which allows access to a specific port 29 | # within the machine from a port on the host machine and only allow access 30 | # via 127.0.0.1 to disable public access 31 | config.vm.network "forwarded_port", guest: 8000, host: 8000, host_ip: "127.0.0.1" 32 | 33 | # Create a private network, which allows host-only access to the machine 34 | # using a specific IP. 35 | # config.vm.network "private_network", ip: "192.168.33.10" 36 | 37 | # Create a public network, which generally matched to bridged network. 38 | # Bridged networks make the machine appear as another physical device on 39 | # your network. 40 | # config.vm.network "public_network" 41 | 42 | # Share an additional folder to the guest VM. The first argument is 43 | # the path on the host to the actual folder. The second argument is 44 | # the path on the guest to mount the folder. And the optional third 45 | # argument is a set of non-required options. 46 | # config.vm.synced_folder "../data", "/vagrant_data" 47 | 48 | # Provider-specific configuration so you can fine-tune various 49 | # backing providers for Vagrant. These expose provider-specific options. 50 | # Example for VirtualBox: 51 | # 52 | # config.vm.provider "virtualbox" do |vb| 53 | # # Display the VirtualBox GUI when booting the machine 54 | # vb.gui = true 55 | # 56 | # # Customize the amount of memory on the VM: 57 | # vb.memory = "1024" 58 | # end 59 | # 60 | # View the documentation for the provider you are using for more 61 | # information on available options. 62 | 63 | # Enable provisioning with a shell script. Additional provisioners such as 64 | # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the 65 | # documentation for more information about their specific syntax and use. 66 | 67 | # System dependencies. 68 | config.vm.provision "shell", privileged: true, inline: <<-SHELL 69 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 70 | echo "deb https://dl.yarnpkg.com/debian/ stable main" >/etc/apt/sources.list.d/yarn.list 71 | 72 | apt-get update 73 | apt-get install -y python3-pip yarn 74 | SHELL 75 | 76 | # User dependencies. 77 | config.vm.provision "shell", privileged: false, inline: <<-SHELL 78 | pip3 install --user pipenv 79 | SHELL 80 | 81 | # Project setup 82 | config.vm.provision "shell", privileged: false, inline: <<-SHELL 83 | cd /vagrant 84 | yarn 85 | cp -p .env.example .env 86 | pipenv install --dev 87 | pipenv run django-admin migrate 88 | pipenv run django-admin loaddata examples/example-data.json 89 | SHELL 90 | 91 | # Show a usage message: 92 | config.vm.provision "shell", privileged: false, run: "always", inline: <<-SHELL 93 | echo "Buza environment ready for use." 94 | echo "To run the Django development server:" 95 | echo "vagrant ssh -c 'cd /vagrant && pipenv run django-admin runserver 0.0.0.0:8000'" 96 | SHELL 97 | 98 | end 99 | -------------------------------------------------------------------------------- /src/buza/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-30 15:13 2 | 3 | from django.conf import settings 4 | import django.contrib.auth.models 5 | import django.contrib.auth.validators 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.utils.timezone 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('auth', '0009_alter_user_last_name_max_length'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='User', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('password', models.CharField(max_length=128, verbose_name='password')), 25 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 26 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 27 | ('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')), 28 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 29 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 30 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 31 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 32 | ('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')), 33 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 34 | ('phone', models.CharField(blank=True, max_length=11, verbose_name='phone number')), 35 | ('school', models.CharField(blank=True, max_length=100, null=True, verbose_name='school name')), 36 | ('school_address', models.CharField(blank=True, max_length=300, null=True, verbose_name='school address')), 37 | ('grade', models.IntegerField(blank=True, default=7, null=True)), 38 | ('photo', models.ImageField(blank=True, upload_to='users/%Y/%m/%d', verbose_name='profile photo')), 39 | ('bio', models.CharField(blank=True, max_length=250, null=True)), 40 | ('groups', 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')), 41 | ], 42 | options={ 43 | 'verbose_name': 'user', 44 | 'verbose_name_plural': 'users', 45 | 'abstract': False, 46 | }, 47 | managers=[ 48 | ('objects', django.contrib.auth.models.UserManager()), 49 | ], 50 | ), 51 | migrations.CreateModel( 52 | name='Answer', 53 | fields=[ 54 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 55 | ('created', models.DateTimeField(auto_now_add=True, db_index=True)), 56 | ('modified', models.DateTimeField(auto_now=True, db_index=True)), 57 | ('body', models.TextField()), 58 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), 59 | ], 60 | options={ 61 | 'abstract': False, 62 | }, 63 | ), 64 | migrations.CreateModel( 65 | name='Question', 66 | fields=[ 67 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 68 | ('created', models.DateTimeField(auto_now_add=True, db_index=True)), 69 | ('modified', models.DateTimeField(auto_now=True, db_index=True)), 70 | ('title', models.CharField(max_length=1024)), 71 | ('body', models.TextField(blank=True)), 72 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), 73 | ], 74 | options={ 75 | 'abstract': False, 76 | }, 77 | ), 78 | migrations.CreateModel( 79 | name='Subject', 80 | fields=[ 81 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 82 | ('title', models.CharField(max_length=1024)), 83 | ('description', models.TextField()), 84 | ], 85 | ), 86 | migrations.AddField( 87 | model_name='question', 88 | name='subject', 89 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='buza.Subject'), 90 | ), 91 | migrations.AddField( 92 | model_name='answer', 93 | name='question', 94 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='buza.Question'), 95 | ), 96 | migrations.AddField( 97 | model_name='user', 98 | name='subjects', 99 | field=models.ManyToManyField(to='buza.Subject'), 100 | ), 101 | migrations.AddField( 102 | model_name='user', 103 | name='user_permissions', 104 | 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'), 105 | ), 106 | ] 107 | -------------------------------------------------------------------------------- /src/buza/templates/accounts/privacy_policy.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {%block title%} Privacy Policy - Buza Answers {%endblock%} 4 | {% block content %} 5 |
6 | 7 |

Privacy Policy for Buza Answers

8 |
9 | Last updated April 2019 10 |

At Buza Answers, accessible from 11 | buza.co.za, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by Buza Answers and how we use it.

12 | 13 |

If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us through email at sewagodimo.matlapeng@gmail.com

14 | 15 |

Log Files: 16 | Buza Answers follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that is personally identifiable. The purpose of the information is for analyzing trends, administering the site, tracking users' movement on the website, and gathering demographic information.

17 | 18 |

Cookies and Web Beacons: 19 | Like any other website, Buza Answers uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information.

20 | 21 | 22 |

Google DoubleClick DART Cookie: 23 | Google is one of a third-party vendor on our site. It also uses cookies, 24 | known as DART cookies, to serve ads to our site visitors based upon their visit to 25 | www.website.com and other sites on the internet. However, visitors may choose to decline 26 | the use of DART cookies by visiting the Google ad and content network Privacy Policy at the following URL – https://policies.google.com/technologies/ads

27 | 28 | 29 |

Our Advertising Partnerse: 30 | Some of advertisers on our site may use cookies and web beacons. Our advertising partners are listed below. Each of our advertising partners has their own Privacy Policy for their policies on user data. For easier access, we hyperlinked to their Privacy Policies below.

31 | 32 | 38 | 39 | 40 |

Privacy Policies: 41 | You may consult this list to find the Privacy Policy for each of the advertising 42 | partners of Buza Answers. Our Privacy Policy was created with the help of the 43 | GDPR Privacy Policy Generator and the Privacy Policy Generator from TermsFeed plus the Terms and Conditions Template.

44 | 45 |

Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web Beacons that are used in their respective advertisements and links that appear on Buza Answers, which are sent directly to users' browser. They automatically receive your IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content that you see on websites that you visit.

46 | 47 |

Note that Buza Answers has no access to or control over these cookies that are used by third-party advertisers.

48 | 49 | 50 |

Third Pary Privacy Policies: 51 | Buza Answers's Privacy Policy does not apply to other advertisers or websites. 52 | Thus, we are advising you to consult the respective Privacy Policies of these third-party ad 53 | servers for more detailed information. It may include their practices and instructions about how 54 | to opt-out of certain options..

55 | 56 |

You can choose to disable cookies through your individual browser options. 57 | To know more detailed information about cookie management with specific web browsers, 58 | it can be found at the browsers' respective websites. What Are Cookies?

59 | 60 |

Your Content: 61 | We collect and store the information and content that you post to the Buza Answers Platform, 62 | including your questions, answers, photos, and comments. Unless you have posted certain content anonymously, 63 | Your Content, date and time stamps, and all associated comments are publicly viewable on the Buza Answers Platform, 64 | along with your name. This also may be indexed by search engines and be republished elsewhere on the Internet 65 | in accordance with our Terms of Service.

66 | 67 |

Children's Information: 68 | Another part of our priority is adding protection for children while using the internet. 69 | We encourage parents and guardians to observe, participate in, and/or monitor and guide their online 70 | activity.

71 | 72 |

Buza Answers does not knowingly collect any Personal Identifiable Information from 73 | children under the age of 13. If you think that your child provided this kind of information on our website, we strongly encourage you to contact us immediately and we will do our best efforts to promptly remove such information from our records.

74 | 75 |

Online Privacy Policy Only: 76 | This Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in Buza Answers. This policy is not applicable to any information collected offline or via channels other than this website.

77 | 78 |

Consent: 79 | By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions.

80 | 81 |

back to our home

82 |
83 |
84 | {% endblock%} -------------------------------------------------------------------------------- /src/buza/templates/accounts/terms_of_service.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {%block title%} Terms of Service - Buza Answers {%endblock%} 4 | 5 | {%block content %} 6 |
7 |

Welcome to Buza Answers

8 |
9 |

Terms of Service for Buza Answers

10 |

These terms and conditions outline the rules and regulations for the use of Buza Answers's Website.


11 | Buza Answers is located at:
12 |
Cape Town
Western Cape , South Africa
13 |
14 |

By accessing this website we assume you accept these terms and conditions in full. Do not continue to use Buza's website 15 | if you do not accept all of the terms and conditions stated on this page.

16 |

The following terminology applies to these Terms and Conditions, Privacy Statement and Disclaimer Notice 17 | and any or all Agreements: “Client”, “You” and “Your” refers to you, the person accessing this website 18 | and accepting the Company’s terms and conditions. “The Company”, “Ourselves”, “We”, “Our” and “Us”, refers 19 | to our Company. “Party”, “Parties”, or “Us”, refers to both the Client and ourselves, or either the Client 20 | or ourselves. All terms refer to the offer, acceptance and consideration of payment necessary to undertake 21 | the process of our assistance to the Client in the most appropriate manner, whether by formal meetings 22 | of a fixed duration, or any other means, for the express purpose of meeting the Client’s needs in respect 23 | of provision of the Company’s stated services/products, in accordance with and subject to, prevailing law 24 | of South Africa. Any use of the above terminology or other words in the singular, plural, 25 | capitalisation and/or he/she or they, are taken as interchangeable and therefore as referring to same.

Cookies

26 |

We employ the use of cookies. By using Buza's website you consent to the use of cookies 27 | in accordance with Buza’s privacy policy.

Most of the modern day interactive web sites 28 | use cookies to enable us to retrieve user details for each visit. Cookies are used in some areas of our site 29 | to enable the functionality of this area and ease of use for those people visiting. Some of our 30 | affiliate / advertising partners may also use cookies.

License

31 |

Unless otherwise stated, Buza and/or it’s licensors own the intellectual property rights for 32 | all material on Buza. All intellectual property rights are reserved. You may view and/or print 33 | pages from http://buza.co.za/home/ for your own personal use subject to restrictions set in these terms and conditions.

34 |

You must not:

35 |
    36 |
  1. Republish material from http://buza.co.za/home/
  2. 37 |
  3. Sell, rent or sub-license material from http://buza.co.za/home/
  4. 38 |
  5. Reproduce, duplicate or copy material from http://buza.co.za/home/
  6. 39 |
40 |

Redistribute content from Buza (unless content is specifically made for redistribution).

41 |

Hyperlinking to our Content

42 |
    43 |
  1. The following organizations may link to our Web site without prior written approval: 44 |
      45 |
    1. Government agencies;
    2. 46 |
    3. Search engines;
    4. 47 |
    5. News organizations;
    6. 48 |
    7. Online directory distributors when they list us in the directory may link to our Web site in the same 49 | manner as they hyperlink to the Web sites of other listed businesses; and
    8. 50 |
    9. Systemwide Accredited Businesses except soliciting non-profit organizations, charity shopping malls, 51 | and charity fundraising groups which may not hyperlink to our Web site.
    10. 52 |
    53 |
  2. 54 |
55 |
    56 |
  1. These organizations may link to our home page, to publications or to other Web site information so long 57 | as the link: (a) is not in any way misleading; (b) does not falsely imply sponsorship, endorsement or 58 | approval of the linking party and its products or services; and (c) fits within the context of the linking 59 | party's site. 60 |
  2. 61 |
  3. We may consider and approve in our sole discretion other link requests from the following types of organizations: 62 |
      63 |
    1. commonly-known consumer and/or business information sources such as Chambers of Commerce, American 64 | Automobile Association, AARP and Consumers Union;
    2. 65 |
    3. dot.com community sites;
    4. 66 |
    5. associations or other groups representing charities, including charity giving sites,
    6. 67 |
    7. online directory distributors;
    8. 68 |
    9. internet portals;
    10. 69 |
    11. accounting, law and consulting firms whose primary clients are businesses; and
    12. 70 |
    13. educational institutions and trade associations.
    14. 71 |
    72 |
  4. 73 |
74 |

We will approve link requests from these organizations if we determine that: (a) the link would not reflect 75 | unfavorably on us or our accredited businesses (for example, trade associations or other organizations 76 | representing inherently suspect types of business, such as work-at-home opportunities, shall not be allowed 77 | to link); (b)the organization does not have an unsatisfactory record with us; (c) the benefit to us from 78 | the visibility associated with the hyperlink outweighs the absence of ; and (d) where the 79 | link is in the context of general resource information or is otherwise consistent with editorial content 80 | in a newsletter or similar product furthering the mission of the organization.

81 | 82 |

These organizations may link to our home page, to publications or to other Web site information so long as 83 | the link: (a) is not in any way misleading; (b) does not falsely imply sponsorship, endorsement or approval 84 | of the linking party and it products or services; and (c) fits within the context of the linking party's 85 | site.

86 | 87 |

If you are among the organizations listed in paragraph 2 above and are interested in linking to our website, 88 | you must notify us by sending an e-mail to buza4education@gmail.com. 89 | Please include your name, your organization name, contact information (such as a phone number and/or e-mail 90 | address) as well as the URL of your site, a list of any URLs from which you intend to link to our Web site, 91 | and a list of the URL(s) on our site to which you would like to link. Allow 2-3 weeks for a response.

92 | 93 |

Approved organizations may hyperlink to our Web site as follows:

94 | 95 |
    96 |
  1. By use of our corporate name; or
  2. 97 |
  3. By use of the uniform resource locator (Web address) being linked to; or
  4. 98 |
  5. By use of any other description of our Web site or material being linked to that makes sense within the 99 | context and format of content on the linking party's site.
  6. 100 |
101 |

No use of Buza’s logo or other artwork will be allowed for linking absent a trademark license 102 | agreement.

103 |

Iframes

104 |

Without prior approval and express written permission, you may not create frames around our Web pages or 105 | use other techniques that alter in any way the visual presentation or appearance of our Web site.

106 |

Reservation of Rights

107 |

We reserve the right at any time and in its sole discretion to request that you remove all links or any particular 108 | link to our Web site. You agree to immediately remove all links to our Web site upon such request. We also 109 | reserve the right to amend these terms and conditions and its linking policy at any time. By continuing 110 | to link to our Web site, you agree to be bound to and abide by these linking terms and conditions.

111 |

Removal of links from our website

112 |

If you find any link on our Web site or any linked web site objectionable for any reason, you may contact 113 | us about this. We will consider requests to remove links but will have no obligation to do so or to respond 114 | directly to you.

115 |

Whilst we endeavour to ensure that the information on this website is correct, we do not warrant its completeness 116 | or accuracy; nor do we commit to ensuring that the website remains available or that the material on the 117 | website is kept up to date.

118 |

Content Liability

119 |

We shall have no responsibility or liability for any content appearing on your Web site. You agree to indemnify 120 | and defend us against all claims arising out of or based upon your Website. No link(s) may appear on any 121 | page on your Web site or within any context containing content or materials that may be interpreted as 122 | libelous, obscene or criminal, or which infringes, otherwise violates, or advocates the infringement or 123 | other violation of, any third party rights.

124 |

Disclaimer

125 |

To the maximum extent permitted by applicable law, we exclude all representations, warranties and conditions relating to our website and the use of this website (including, without limitation, any warranties implied by law in respect of satisfactory quality, fitness for purpose and/or the use of reasonable care and skill). Nothing in this disclaimer will:

126 |
    127 |
  1. limit or exclude our or your liability for death or personal injury resulting from negligence;
  2. 128 |
  3. limit or exclude our or your liability for fraud or fraudulent misrepresentation;
  4. 129 |
  5. limit any of our or your liabilities in any way that is not permitted under applicable law; or
  6. 130 |
  7. exclude any of our or your liabilities that may not be excluded under applicable law.
  8. 131 |
132 |

The limitations and exclusions of liability set out in this Section and elsewhere in this disclaimer: (a) 133 | are subject to the preceding paragraph; and (b) govern all liabilities arising under the disclaimer or 134 | in relation to the subject matter of this disclaimer, including liabilities arising in contract, in tort 135 | (including negligence) and for breach of statutory duty.

136 |

To the extent that the website and the information and services on the website are provided free of charge, 137 | we will not be liable for any loss or damage of any nature.

138 | 139 |

back to our home

140 |
141 |
142 | {% endblock%} -------------------------------------------------------------------------------- /src/buza/views.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from crispy_forms import layout 4 | from crispy_forms.helper import FormHelper 5 | from django import forms 6 | from django.contrib.auth.forms import UserCreationForm 7 | from django.contrib.auth.mixins import LoginRequiredMixin 8 | from django.core.exceptions import PermissionDenied 9 | from django.db.models import BooleanField, Exists, OuterRef, QuerySet, Value 10 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect 11 | from django.shortcuts import get_object_or_404, render 12 | from django.urls import reverse 13 | from django.views import generic 14 | from django.views.generic.edit import FormMixin, ModelFormMixin 15 | 16 | from buza import models 17 | 18 | 19 | class CrispyFormMixin(FormMixin): 20 | """ 21 | Helper class for crispy-forms rendering. 22 | """ 23 | 24 | def get_form( 25 | self, 26 | form_class: Optional[Type[forms.BaseForm]] = None, 27 | ) -> forms.BaseForm: 28 | """ 29 | Add this view's crispy-forms ``helper`` to the form instance. 30 | """ 31 | form = super().get_form(form_class) 32 | form.helper = self.get_form_helper(form) 33 | return form 34 | 35 | def get_form_helper(self, form: forms.BaseForm) -> FormHelper: 36 | """ 37 | Return the `FormHelper` to use for this view. 38 | 39 | Extend this to customise 40 | """ 41 | return FormHelper(form) 42 | 43 | 44 | # TODO: Migrate to class based views 45 | 46 | 47 | class BuzaUserCreationForm(UserCreationForm): 48 | """ 49 | Like Django's `UserCreationForm`, but point at Buza's `User` model. 50 | """ 51 | 52 | class Meta(UserCreationForm.Meta): 53 | model = models.User 54 | 55 | 56 | def register(request: HttpRequest) -> HttpResponse: 57 | """ 58 | Register a user account. 59 | """ 60 | if request.method == 'POST': 61 | user_form = BuzaUserCreationForm(request.POST) 62 | 63 | if user_form.is_valid(): 64 | # Save the new user. 65 | new_user = user_form.save() 66 | return render( 67 | request, 68 | 'accounts/register_done.html', 69 | {'new_user': new_user}, 70 | ) 71 | else: 72 | # User did not fill in form correctly 73 | user_form = BuzaUserCreationForm() 74 | return render( 75 | request, 76 | 'accounts/register.html', 77 | {'user_form': user_form}, 78 | ) 79 | 80 | 81 | class HomePageView(generic.RedirectView): 82 | permanent = False 83 | query_string = True 84 | pattern_name = 'login' 85 | 86 | def get_redirect_url(self, *args, **kwargs): 87 | user = self.request.user 88 | if user.is_authenticated: 89 | return reverse('user-detail', kwargs=dict(pk=user.pk)) 90 | else: 91 | return '/auth/login/' 92 | 93 | 94 | class PrivacyPolicyView(generic.TemplateView): 95 | template_name = "accounts/privacy_policy.html" 96 | 97 | 98 | class TermsOfService(generic.TemplateView): 99 | template_name = "accounts/terms_of_service.html" 100 | 101 | 102 | class UserUpdate(CrispyFormMixin, LoginRequiredMixin, generic.UpdateView): 103 | model = models.User 104 | fields = [ 105 | 'email', 106 | 'phone', 107 | 'photo', 108 | 'first_name', 109 | 'last_name', 110 | 'school', 111 | 'school_address', 112 | 'grade', 113 | 'bio', 114 | ] 115 | 116 | # TODO: Merge with and migrate to existing buza/user_form.html ? 117 | template_name = 'accounts/edit.html' 118 | 119 | def get_object(self, queryset: QuerySet = None) -> models.User: 120 | """ 121 | Only allow users to update their own profile. 122 | """ 123 | user: models.User = super().get_object(queryset) 124 | if not user == self.request.user: 125 | raise PermissionDenied('You can only update your own profile.') 126 | return user 127 | 128 | def get_form_helper(self, form: forms.ModelForm) -> FormHelper: 129 | user: models.User = self.object 130 | helper = super().get_form_helper(form) 131 | helper.form_action = reverse('user-update', kwargs=dict(pk=user.pk)) 132 | helper.add_input(layout.Submit('submit', 'Save Changes')) 133 | return helper 134 | 135 | def get_success_url(self) -> str: 136 | user: models.User = self.object 137 | success_url: str = reverse('user-detail', kwargs=dict(pk=user.pk)) 138 | return success_url 139 | 140 | 141 | class SubjectDetail(generic.DetailView): 142 | model = models.Subject 143 | 144 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 145 | """ 146 | Add the question to the context. 147 | """ 148 | context_data: Dict[str, Any] = super().get_context_data(**kwargs) 149 | queryset = models.Subject.objects.all() 150 | user: models.RequestUser = self.request.user 151 | 152 | if user.is_authenticated: 153 | # Subquery existence check for relation to user: 154 | queryset = queryset.annotate( 155 | user_following=Exists( 156 | models.User.objects.filter(pk=user.pk, subjects=OuterRef('pk')), 157 | ), 158 | ) 159 | else: 160 | # For anonymous users, always false. 161 | queryset = queryset.annotate( 162 | user_following=Value(False, output_field=BooleanField()), 163 | ) 164 | queryset = queryset.order_by('-user_following', 'title') 165 | context_data.setdefault('subject_list', queryset) 166 | return context_data 167 | 168 | def post(self, request, *args, **kwargs): 169 | user: models.RequestUser = self.request.user 170 | subject: models.Subject = models.Subject.objects.get(pk=kwargs.get('pk')) 171 | if user.is_authenticated: 172 | if 'follow-subject' in request.POST: 173 | follow_subject: models.Subject = models.Subject.objects.get( 174 | pk=request.POST['follow-subject'], 175 | ) 176 | request.user.subjects.add(follow_subject) 177 | return HttpResponseRedirect( 178 | reverse('subject-detail', kwargs=dict(pk=subject.pk))) 179 | elif 'following-subject' in request.POST: 180 | follow_subject = models.Subject.objects.get( 181 | pk=request.POST['following-subject'], 182 | ) 183 | request.user.subjects.remove(follow_subject) 184 | return HttpResponseRedirect( 185 | reverse('subject-detail', kwargs=dict(pk=follow_subject.pk))) 186 | else: 187 | return HttpResponseRedirect( 188 | reverse('subject-detail', kwargs=dict(pk=subject.pk))) 189 | return HttpResponseRedirect( 190 | f'/auth/login/?next=/subjects/{subject.pk}/') 191 | 192 | 193 | class UserDetail(generic.DetailView): 194 | model = models.User 195 | 196 | # Avoid conflicting with 'user' (the logged-in user) 197 | context_object_name = 'user_object' 198 | 199 | 200 | class QuestionDetail(generic.DetailView): 201 | model = models.Question 202 | 203 | 204 | class QuestionList(generic.ListView): 205 | model = models.Question 206 | ordering = ['-created'] 207 | 208 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 209 | """ 210 | Add the question to the context. 211 | """ 212 | context_data: Dict[str, Any] = super().get_context_data(**kwargs) 213 | queryset = models.Subject.objects.all() 214 | user: models.RequestUser = self.request.user 215 | 216 | if user.is_authenticated: 217 | # Subquery existence check for relation to user: 218 | queryset = queryset.annotate( 219 | user_following=Exists( 220 | models.User.objects.filter(pk=user.pk, subjects=OuterRef('pk')), 221 | ), 222 | ) 223 | else: 224 | # For anonymous users, always false. 225 | queryset = queryset.annotate( 226 | user_following=Value(False, output_field=BooleanField()), 227 | ) 228 | queryset = queryset.order_by('-user_following', 'title') 229 | context_data.setdefault('subject_list', queryset) 230 | return context_data 231 | 232 | def post(self, request, *args, **kwargs): 233 | user: models.RequestUser = self.request.user 234 | if user.is_authenticated: 235 | if 'follow-subject' in request.POST: 236 | follow_subject: models.Subject = models.Subject.objects.get( 237 | pk=request.POST['follow-subject'], 238 | ) 239 | request.user.subjects.add(follow_subject) 240 | return HttpResponseRedirect( 241 | reverse( 242 | 'subject-detail', 243 | kwargs=dict(pk=request.POST['follow-subject'])), 244 | ) 245 | elif 'following-subject' in request.POST: 246 | following_subject = models.Subject.objects.get( 247 | pk=request.POST['following-subject'], 248 | ) 249 | request.user.subjects.remove(following_subject) 250 | return HttpResponseRedirect( 251 | reverse('subject-detail', kwargs=dict(pk=following_subject.pk))) 252 | else: 253 | return HttpResponseRedirect( 254 | reverse('question-list')) 255 | return HttpResponseRedirect( 256 | f'/auth/login/?next=/') 257 | 258 | 259 | class QuestionModelFormMixin(CrispyFormMixin, LoginRequiredMixin, ModelFormMixin): 260 | """ 261 | Base class for the Question create & update views. 262 | """ 263 | model = models.Question 264 | fields = [ 265 | 'title', 266 | 'body', 267 | ] 268 | subject: models.Subject 269 | 270 | def get_success_url(self) -> str: 271 | """ 272 | Redirect to the question. 273 | """ 274 | question: models.Question = self.object 275 | success_url: str = reverse('question-detail', kwargs=dict(pk=question.pk)) 276 | return success_url 277 | 278 | 279 | class QuestionCreate(QuestionModelFormMixin, generic.CreateView): 280 | 281 | def dispatch( 282 | self, 283 | request: HttpRequest, 284 | *args: Any, 285 | subject_pk: int, 286 | **kwargs: Any, 287 | ) -> HttpResponse: 288 | """ 289 | Look up the question, and set `self.question`. 290 | """ 291 | self.subject: models.Subject = get_object_or_404(models.Subject, pk=subject_pk) 292 | return super().dispatch(request, *args, **kwargs) 293 | 294 | def get_form_helper(self, form: forms.ModelForm) -> FormHelper: 295 | helper = super().get_form_helper(form) 296 | helper.form_action = reverse( 297 | 'question-create', 298 | kwargs=dict(subject_pk=self.subject.pk), 299 | ) 300 | helper.add_input(layout.Submit( 301 | name='submit', 302 | value='Ask question', 303 | css_class='btn-buza-green', 304 | )) 305 | return helper 306 | 307 | def form_valid(self, form: forms.ModelForm) -> HttpResponse: 308 | """ 309 | Set the question's author to the posting user. 310 | """ 311 | question: models.Question = form.instance 312 | author: models.User = self.request.user 313 | assert author.is_authenticated, author 314 | question.author = author 315 | question.subject = self.subject 316 | question.grade = author.grade 317 | return super().form_valid(form) 318 | 319 | 320 | class QuestionUpdate(QuestionModelFormMixin, generic.UpdateView): 321 | 322 | def get_object(self, queryset=None): 323 | """ 324 | Permission check: Users can only edit their own questions. 325 | 326 | TODO (Pi): Use django-auth-utils for this? 327 | """ 328 | question: models.Question = super().get_object(queryset) 329 | if question.author != self.request.user: 330 | raise PermissionDenied('You can only edit your own questions.') 331 | return question 332 | 333 | def get_form_helper(self, form: forms.ModelForm) -> FormHelper: 334 | helper = super().get_form_helper(form) 335 | helper.form_action = reverse( 336 | 'question-update', 337 | kwargs=dict(pk=form.instance.pk), 338 | ) 339 | helper.add_input(layout.Submit( 340 | name='submit', 341 | value='Save', 342 | css_class='btn-buza-green', 343 | )) 344 | return helper 345 | 346 | 347 | class AnswerCreate(LoginRequiredMixin, generic.CreateView): 348 | """ 349 | Post a new answer to a question. 350 | 351 | Expects `question_pk` as a view argument. 352 | """ 353 | 354 | model = models.Answer 355 | fields = [ 356 | 'body', 357 | ] 358 | 359 | question: models.Question 360 | 361 | def dispatch( 362 | self, 363 | request: HttpRequest, 364 | *args: Any, 365 | question_pk: int, 366 | **kwargs: Any, 367 | ) -> HttpResponse: 368 | """ 369 | Look up the question, and set `self.question`. 370 | """ 371 | self.question = get_object_or_404(models.Question, pk=question_pk) 372 | return super().dispatch(request, *args, **kwargs) 373 | 374 | def form_valid(self, form: forms.ModelForm) -> HttpResponse: 375 | """ 376 | Set the answer's author to the posting user. 377 | """ 378 | answer: models.Answer = form.instance 379 | author: models.User = self.request.user 380 | assert author.is_authenticated, author 381 | answer.author = author 382 | answer.question = self.question 383 | return super().form_valid(form) 384 | 385 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 386 | """ 387 | Add the question to the context. 388 | """ 389 | context_data: Dict[str, Any] = super().get_context_data(**kwargs) 390 | context_data.setdefault('question', self.question) 391 | return context_data 392 | 393 | def get_success_url(self) -> str: 394 | """ 395 | Redirect to the question. 396 | """ 397 | answer: models.Answer = self.object 398 | question: models.Question = answer.question 399 | success_url: str = reverse('question-detail', kwargs=dict(pk=question.pk)) 400 | return success_url 401 | 402 | 403 | class AnswerUpdate(LoginRequiredMixin, generic.UpdateView): 404 | """ 405 | Post a new answer to a question. 406 | 407 | Expects `question_pk` as a view argument. 408 | """ 409 | 410 | model = models.Answer 411 | fields = [ 412 | 'body', 413 | ] 414 | 415 | def get_object(self, queryset: QuerySet = None) -> models.Answer: 416 | """ 417 | Permission check: Users can only edit their own answers. 418 | 419 | TODO (Pi): Use django-auth-utils for this? 420 | """ 421 | answer: models.Answer = super().get_object(queryset) 422 | if answer.author != self.request.user: 423 | raise PermissionDenied('You can only edit your own answers.') 424 | return answer 425 | 426 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 427 | """ 428 | Add the question to the context. 429 | """ 430 | answer: models.Answer = self.object 431 | context_data: Dict[str, Any] = super().get_context_data(**kwargs) 432 | context_data.setdefault('question', answer.question) 433 | return context_data 434 | 435 | def get_success_url(self) -> str: 436 | """ 437 | Redirect to the question. 438 | """ 439 | answer: models.Answer = self.object 440 | question: models.Question = answer.question 441 | success_url: str = reverse('question-detail', kwargs=dict(pk=question.pk)) 442 | return success_url 443 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "1d6cbe29aca0a1267c50b66f0091ab3acb4c099ef8a3ed269881971d58ee07a0" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "buza-website": { 20 | "editable": true, 21 | "path": "." 22 | }, 23 | "certifi": { 24 | "hashes": [ 25 | "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", 26 | "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" 27 | ], 28 | "version": "==2019.3.9" 29 | }, 30 | "chardet": { 31 | "hashes": [ 32 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 33 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 34 | ], 35 | "version": "==3.0.4" 36 | }, 37 | "defusedxml": { 38 | "hashes": [ 39 | "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", 40 | "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" 41 | ], 42 | "markers": "python_version >= '3.0'", 43 | "version": "==0.6.0" 44 | }, 45 | "django": { 46 | "hashes": [ 47 | "sha256:0fd54e4f27bc3e0b7054a11e6b3a18fa53f2373f6b2df8a22e8eadfe018970a5", 48 | "sha256:f3b28084101d516f56104856761bc247f85a2a5bbd9da39d9f6197ff461b3ee4" 49 | ], 50 | "version": "==2.1.8" 51 | }, 52 | "django-crispy-forms": { 53 | "hashes": [ 54 | "sha256:5952bab971110d0b86c278132dae0aa095beee8f723e625c3d3fa28888f1675f", 55 | "sha256:705ededc554ad8736157c666681165fe22ead2dec0d5446d65fc9dd976a5a876" 56 | ], 57 | "version": "==1.7.2" 58 | }, 59 | "django-environ": { 60 | "hashes": [ 61 | "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", 62 | "sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4" 63 | ], 64 | "version": "==0.4.5" 65 | }, 66 | "django-taggit": { 67 | "hashes": [ 68 | "sha256:01bf163f66f385de3777378f43338aba93aae8673891d8ba9a20695b2ffb8e10", 69 | "sha256:c13dfc1808a3084b64898e591af1d2f49b672d108388654804b170ee0ac5caf0" 70 | ], 71 | "version": "==1.1.0" 72 | }, 73 | "humanize": { 74 | "hashes": [ 75 | "sha256:a43f57115831ac7c70de098e6ac46ac13be00d69abbf60bdcac251344785bb19" 76 | ], 77 | "index": "pypi", 78 | "version": "==0.5.1" 79 | }, 80 | "idna": { 81 | "hashes": [ 82 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 83 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 84 | ], 85 | "version": "==2.8" 86 | }, 87 | "oauthlib": { 88 | "hashes": [ 89 | "sha256:0ce32c5d989a1827e3f1148f98b9085ed2370fc939bf524c9c851d8714797298", 90 | "sha256:3e1e14f6cde7e5475128d30e97edc3bfb4dc857cb884d8714ec161fdbb3b358e" 91 | ], 92 | "version": "==3.0.1" 93 | }, 94 | "pillow": { 95 | "hashes": [ 96 | "sha256:15c056bfa284c30a7f265a41ac4cbbc93bdbfc0dfe0613b9cb8a8581b51a9e55", 97 | "sha256:1a4e06ba4f74494ea0c58c24de2bb752818e9d504474ec95b0aa94f6b0a7e479", 98 | "sha256:1c3c707c76be43c9e99cb7e3d5f1bee1c8e5be8b8a2a5eeee665efbf8ddde91a", 99 | "sha256:1fd0b290203e3b0882d9605d807b03c0f47e3440f97824586c173eca0aadd99d", 100 | "sha256:24114e4a6e1870c5a24b1da8f60d0ba77a0b4027907860188ea82bd3508c80eb", 101 | "sha256:258d886a49b6b058cd7abb0ab4b2b85ce78669a857398e83e8b8e28b317b5abb", 102 | "sha256:33c79b6dd6bc7f65079ab9ca5bebffb5f5d1141c689c9c6a7855776d1b09b7e8", 103 | "sha256:367385fc797b2c31564c427430c7a8630db1a00bd040555dfc1d5c52e39fcd72", 104 | "sha256:3c1884ff078fb8bf5f63d7d86921838b82ed4a7d0c027add773c2f38b3168754", 105 | "sha256:44e5240e8f4f8861d748f2a58b3f04daadab5e22bfec896bf5434745f788f33f", 106 | "sha256:46aa988e15f3ea72dddd81afe3839437b755fffddb5e173886f11460be909dce", 107 | "sha256:74d90d499c9c736d52dd6d9b7221af5665b9c04f1767e35f5dd8694324bd4601", 108 | "sha256:809c0a2ce9032cbcd7b5313f71af4bdc5c8c771cb86eb7559afd954cab82ebb5", 109 | "sha256:85d1ef2cdafd5507c4221d201aaf62fc9276f8b0f71bd3933363e62a33abc734", 110 | "sha256:8c3889c7681af77ecfa4431cd42a2885d093ecb811e81fbe5e203abc07e0995b", 111 | "sha256:9218d81b9fca98d2c47d35d688a0cea0c42fd473159dfd5612dcb0483c63e40b", 112 | "sha256:9aa4f3827992288edd37c9df345783a69ef58bd20cc02e64b36e44bcd157bbf1", 113 | "sha256:9d80f44137a70b6f84c750d11019a3419f409c944526a95219bea0ac31f4dd91", 114 | "sha256:b7ebd36128a2fe93991293f997e44be9286503c7530ace6a55b938b20be288d8", 115 | "sha256:c4c78e2c71c257c136cdd43869fd3d5e34fc2162dc22e4a5406b0ebe86958239", 116 | "sha256:c6a842537f887be1fe115d8abb5daa9bc8cc124e455ff995830cc785624a97af", 117 | "sha256:cf0a2e040fdf5a6d95f4c286c6ef1df6b36c218b528c8a9158ec2452a804b9b8", 118 | "sha256:cfd28aad6fc61f7a5d4ee556a997dc6e5555d9381d1390c00ecaf984d57e4232", 119 | "sha256:dca5660e25932771460d4688ccbb515677caaf8595f3f3240ec16c117deff89a", 120 | "sha256:de7aedc85918c2f887886442e50f52c1b93545606317956d65f342bd81cb4fc3", 121 | "sha256:e6c0bbf8e277b74196e3140c35f9a1ae3eafd818f7f2d3a15819c49135d6c062" 122 | ], 123 | "version": "==6.0.0" 124 | }, 125 | "psycopg2-binary": { 126 | "hashes": [ 127 | "sha256:007ca0df127b1862fc010125bc4100b7a630efc6841047bd11afceadb4754611", 128 | "sha256:03c49e02adf0b4d68f422fdbd98f7a7c547beb27e99a75ed02298f85cb48406a", 129 | "sha256:0a1232cdd314e08848825edda06600455ad2a7adaa463ebfb12ece2d09f3370e", 130 | "sha256:131c80d0958c89273d9720b9adf9df1d7600bb3120e16019a7389ab15b079af5", 131 | "sha256:2de34cc3b775724623f86617d2601308083176a495f5b2efc2bbb0da154f483a", 132 | "sha256:2eddc31500f73544a2a54123d4c4b249c3c711d31e64deddb0890982ea37397a", 133 | "sha256:484f6c62bdc166ee0e5be3aa831120423bf399786d1f3b0304526c86180fbc0b", 134 | "sha256:4c2d9369ed40b4a44a8ccd6bc3a7db6272b8314812d2d1091f95c4c836d92e06", 135 | "sha256:70f570b5fa44413b9f30dbc053d17ef3ce6a4100147a10822f8662e58d473656", 136 | "sha256:7a2b5b095f3bd733aab101c89c0e1a3f0dfb4ebdc26f6374805c086ffe29d5b2", 137 | "sha256:804914a669186e2843c1f7fbe12b55aad1b36d40a28274abe6027deffad9433d", 138 | "sha256:8520c03172da18345d012949a53617a963e0191ccb3c666f23276d5326af27b5", 139 | "sha256:90da901fc33ea393fc644607e4a3916b509387e9339ec6ebc7bfded45b7a0ae9", 140 | "sha256:a582416ad123291a82c300d1d872bdc4136d69ad0b41d57dc5ca3df7ef8e3088", 141 | "sha256:ac8c5e20309f4989c296d62cac20ee456b69c41fd1bc03829e27de23b6fa9dd0", 142 | "sha256:b2cf82f55a619879f8557fdaae5cec7a294fac815e0087c4f67026fdf5259844", 143 | "sha256:b59d6f8cfca2983d8fdbe457bf95d2192f7b7efdb2b483bf5fa4e8981b04e8b2", 144 | "sha256:be08168197021d669b9964bd87628fa88f910b1be31e7010901070f2540c05fd", 145 | "sha256:be0f952f1c365061041bad16e27e224e29615d4eb1fb5b7e7760a1d3d12b90b6", 146 | "sha256:c1c9a33e46d7c12b9c96cf2d4349d783e3127163fd96254dcd44663cf0a1d438", 147 | "sha256:d18c89957ac57dd2a2724ecfe9a759912d776f96ecabba23acb9ecbf5c731035", 148 | "sha256:d7e7b0ff21f39433c50397e60bf0995d078802c591ca3b8d99857ea18a7496ee", 149 | "sha256:da0929b2bf0d1f365345e5eb940d8713c1d516312e010135b14402e2a3d2404d", 150 | "sha256:de24a4962e361c512d3e528ded6c7480eab24c655b8ca1f0b761d3b3650d2f07", 151 | "sha256:e45f93ff3f7dae2202248cf413a87aeb330821bf76998b3cf374eda2fc893dd7", 152 | "sha256:f046aeae1f7a845041b8661bb7a52449202b6c5d3fb59eb4724e7ca088811904", 153 | "sha256:f1dc2b7b2748084b890f5d05b65a47cd03188824890e9a60818721fd492249fb", 154 | "sha256:fcbe7cf3a786572b73d2cd5f34ed452a5f5fac47c9c9d1e0642c457a148f9f88" 155 | ], 156 | "index": "pypi", 157 | "version": "==2.8.2" 158 | }, 159 | "pyjwt": { 160 | "hashes": [ 161 | "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", 162 | "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" 163 | ], 164 | "version": "==1.7.1" 165 | }, 166 | "python3-openid": { 167 | "hashes": [ 168 | "sha256:0086da6b6ef3161cfe50fb1ee5cceaf2cda1700019fda03c2c5c440ca6abe4fa", 169 | "sha256:628d365d687e12da12d02c6691170f4451db28d6d68d050007e4a40065868502" 170 | ], 171 | "markers": "python_version >= '3.0'", 172 | "version": "==3.1.0" 173 | }, 174 | "pytz": { 175 | "hashes": [ 176 | "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", 177 | "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" 178 | ], 179 | "version": "==2019.1" 180 | }, 181 | "requests": { 182 | "hashes": [ 183 | "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", 184 | "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" 185 | ], 186 | "version": "==2.21.0" 187 | }, 188 | "requests-oauthlib": { 189 | "hashes": [ 190 | "sha256:bd6533330e8748e94bf0b214775fed487d309b8b8fe823dc45641ebcd9a32f57", 191 | "sha256:d3ed0c8f2e3bbc6b344fa63d6f933745ab394469da38db16bdddb461c7e25140" 192 | ], 193 | "version": "==1.2.0" 194 | }, 195 | "six": { 196 | "hashes": [ 197 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 198 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 199 | ], 200 | "version": "==1.12.0" 201 | }, 202 | "social-auth-app-django": { 203 | "hashes": [ 204 | "sha256:6d0dd18c2d9e71ca545097d57b44d26f59e624a12833078e8e52f91baf849778", 205 | "sha256:9237e3d7b6f6f59494c3b02e0cce6efc69c9d33ad9d1a064e3b2318bcbe89ae3", 206 | "sha256:f151396e5b16e2eee12cd2e211004257826ece24fc4ae97a147df386c1cd7082" 207 | ], 208 | "version": "==3.1.0" 209 | }, 210 | "social-auth-core": { 211 | "hashes": [ 212 | "sha256:65122fb4287c70ff7915be0f52150fc1a9b9515eab3c3f0e4cd9dbb2a442a5c3", 213 | "sha256:cc871fb4528f7cbba67efdba0bc0f7d7c6eeb92113b0cdc9368dd91ffe965782", 214 | "sha256:f9f36dfa6af2823efb35a5ef65dfd02f66c944f389c33c25dd9621f8bb75a7da" 215 | ], 216 | "version": "==3.1.0" 217 | }, 218 | "urllib3": { 219 | "hashes": [ 220 | "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", 221 | "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" 222 | ], 223 | "version": "==1.24.2" 224 | } 225 | }, 226 | "develop": { 227 | "atomicwrites": { 228 | "hashes": [ 229 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 230 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 231 | ], 232 | "version": "==1.3.0" 233 | }, 234 | "attrs": { 235 | "hashes": [ 236 | "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", 237 | "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" 238 | ], 239 | "version": "==19.1.0" 240 | }, 241 | "entrypoints": { 242 | "hashes": [ 243 | "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", 244 | "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" 245 | ], 246 | "version": "==0.3" 247 | }, 248 | "flake8": { 249 | "hashes": [ 250 | "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", 251 | "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" 252 | ], 253 | "index": "pypi", 254 | "version": "==3.7.7" 255 | }, 256 | "flake8-commas": { 257 | "hashes": [ 258 | "sha256:d3005899466f51380387df7151fb59afec666a0f4f4a2c6a8995b975de0f44b7", 259 | "sha256:ee2141a3495ef9789a3894ed8802d03eff1eaaf98ce6d8653a7c573ef101935e" 260 | ], 261 | "index": "pypi", 262 | "version": "==2.0.0" 263 | }, 264 | "isort": { 265 | "hashes": [ 266 | "sha256:01cb7e1ca5e6c5b3f235f0385057f70558b70d2f00320208825fa62887292f43", 267 | "sha256:268067462aed7eb2a1e237fcb287852f22077de3fb07964e87e00f829eea2d1a" 268 | ], 269 | "index": "pypi", 270 | "version": "==4.3.17" 271 | }, 272 | "mccabe": { 273 | "hashes": [ 274 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 275 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 276 | ], 277 | "version": "==0.6.1" 278 | }, 279 | "more-itertools": { 280 | "hashes": [ 281 | "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", 282 | "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" 283 | ], 284 | "markers": "python_version > '2.7'", 285 | "version": "==7.0.0" 286 | }, 287 | "mypy": { 288 | "hashes": [ 289 | "sha256:2afe51527b1f6cdc4a5f34fc90473109b22bf7f21086ba3e9451857cf11489e6", 290 | "sha256:56a16df3e0abb145d8accd5dbb70eba6c4bd26e2f89042b491faa78c9635d1e2", 291 | "sha256:5764f10d27b2e93c84f70af5778941b8f4aa1379b2430f85c827e0f5464e8714", 292 | "sha256:5bbc86374f04a3aa817622f98e40375ccb28c4836f36b66706cf3c6ccce86eda", 293 | "sha256:6a9343089f6377e71e20ca734cd8e7ac25d36478a9df580efabfe9059819bf82", 294 | "sha256:6c9851bc4a23dc1d854d3f5dfd5f20a016f8da86bcdbb42687879bb5f86434b0", 295 | "sha256:b8e85956af3fcf043d6f87c91cbe8705073fc67029ba6e22d3468bfee42c4823", 296 | "sha256:b9a0af8fae490306bc112229000aa0c2ccc837b49d29a5c42e088c132a2334dd", 297 | "sha256:bbf643528e2a55df2c1587008d6e3bda5c0445f1240dfa85129af22ae16d7a9a", 298 | "sha256:c46ab3438bd21511db0f2c612d89d8344154c0c9494afc7fbc932de514cf8d15", 299 | "sha256:f7a83d6bd805855ef83ec605eb01ab4fa42bcef254b13631e451cbb44914a9b0" 300 | ], 301 | "index": "pypi", 302 | "version": "==0.701" 303 | }, 304 | "mypy-extensions": { 305 | "hashes": [ 306 | "sha256:37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812", 307 | "sha256:b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e" 308 | ], 309 | "version": "==0.4.1" 310 | }, 311 | "pluggy": { 312 | "hashes": [ 313 | "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", 314 | "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" 315 | ], 316 | "version": "==0.9.0" 317 | }, 318 | "py": { 319 | "hashes": [ 320 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 321 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 322 | ], 323 | "version": "==1.8.0" 324 | }, 325 | "pycodestyle": { 326 | "hashes": [ 327 | "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", 328 | "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" 329 | ], 330 | "version": "==2.5.0" 331 | }, 332 | "pyflakes": { 333 | "hashes": [ 334 | "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", 335 | "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" 336 | ], 337 | "version": "==2.1.1" 338 | }, 339 | "pytest": { 340 | "hashes": [ 341 | "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d", 342 | "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5" 343 | ], 344 | "index": "pypi", 345 | "version": "==4.4.1" 346 | }, 347 | "pytest-django": { 348 | "hashes": [ 349 | "sha256:30d773f1768e8f214a3106f1090e00300ce6edfcac8c55fd13b675fe1cbd1c85", 350 | "sha256:4d3283e774fe1d40630ee58bf34929b83875e4751b525eeb07a7506996eb42ee" 351 | ], 352 | "index": "pypi", 353 | "version": "==3.4.8" 354 | }, 355 | "setuptools-scm": { 356 | "hashes": [ 357 | "sha256:057a67cb0a33e0f95edd828e47809f49b7104f4bc333a98fd35d4d05738c6187", 358 | "sha256:52ab47715fa0fc7d8e6cd15168d1a69ba995feb1505131c3e814eb7087b57358" 359 | ], 360 | "index": "pypi", 361 | "version": "==3.2.0" 362 | }, 363 | "six": { 364 | "hashes": [ 365 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 366 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 367 | ], 368 | "version": "==1.12.0" 369 | }, 370 | "typed-ast": { 371 | "hashes": [ 372 | "sha256:04894d268ba6eab7e093d43107869ad49e7b5ef40d1a94243ea49b352061b200", 373 | "sha256:16616ece19daddc586e499a3d2f560302c11f122b9c692bc216e821ae32aa0d0", 374 | "sha256:252fdae740964b2d3cdfb3f84dcb4d6247a48a6abe2579e8029ab3be3cdc026c", 375 | "sha256:2af80a373af123d0b9f44941a46df67ef0ff7a60f95872412a145f4500a7fc99", 376 | "sha256:2c88d0a913229a06282b285f42a31e063c3bf9071ff65c5ea4c12acb6977c6a7", 377 | "sha256:2ea99c029ebd4b5a308d915cc7fb95b8e1201d60b065450d5d26deb65d3f2bc1", 378 | "sha256:3d2e3ab175fc097d2a51c7a0d3fda442f35ebcc93bb1d7bd9b95ad893e44c04d", 379 | "sha256:4766dd695548a15ee766927bf883fb90c6ac8321be5a60c141f18628fb7f8da8", 380 | "sha256:56b6978798502ef66625a2e0f80cf923da64e328da8bbe16c1ff928c70c873de", 381 | "sha256:5cddb6f8bce14325b2863f9d5ac5c51e07b71b462361fd815d1d7706d3a9d682", 382 | "sha256:644ee788222d81555af543b70a1098f2025db38eaa99226f3a75a6854924d4db", 383 | "sha256:64cf762049fc4775efe6b27161467e76d0ba145862802a65eefc8879086fc6f8", 384 | "sha256:68c362848d9fb71d3c3e5f43c09974a0ae319144634e7a47db62f0f2a54a7fa7", 385 | "sha256:6c1f3c6f6635e611d58e467bf4371883568f0de9ccc4606f17048142dec14a1f", 386 | "sha256:b213d4a02eec4ddf622f4d2fbc539f062af3788d1f332f028a2e19c42da53f15", 387 | "sha256:bb27d4e7805a7de0e35bd0cb1411bc85f807968b2b0539597a49a23b00a622ae", 388 | "sha256:c9d414512eaa417aadae7758bc118868cd2396b0e6138c1dd4fda96679c079d3", 389 | "sha256:f0937165d1e25477b01081c4763d2d9cdc3b18af69cb259dd4f640c9b900fe5e", 390 | "sha256:fb96a6e2c11059ecf84e6741a319f93f683e440e341d4489c9b161eca251cf2a", 391 | "sha256:fc71d2d6ae56a091a8d94f33ec9d0f2001d1cb1db423d8b4355debfe9ce689b7" 392 | ], 393 | "version": "==1.3.4" 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /examples/example-data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "buza.subject", 4 | "pk": 1, 5 | "fields": { 6 | "title": "English", 7 | "description": "Literature, most generically, is any body of written works. More restrictively, literature refers to writing considered to be an art form, or any single writing deemed to have artistic or intellectual value, often due to deploying language in ways that differ from ordinary usage.\r\n\r\nIts Latin root literatura/litteratura (derived itself from littera: letter or handwriting) was used to refer to all written accounts, though contemporary definitions extend the term to include texts that are spoken or sung (oral literature). The concept has changed meaning over time: nowadays it can broaden to have non-written verbal art forms, and thus it is difficult to agree on its origin, which can be paired with that of language or writing itself. Developments in print technology have allowed an ever-growing distribution and proliferation of written works, culminating in electronic literature." 8 | } 9 | }, 10 | { 11 | "model": "buza.subject", 12 | "pk": 2, 13 | "fields": { 14 | "title": "Life Science", 15 | "description": "The life sciences or biological sciences comprise the branches of science that involve the scientific study of life and organisms \u2013 such as microorganisms, plants, and animals including human beings.\r\n\r\nLife science is one of the two major branches of natural science, the other being physical science, which is concerned with non-living matter.\r\n\r\nBy definition, biology is the natural science that studies life and living organisms, with the other life sciences being its sub-disciplines.\r\n\r\nSome life sciences focus on a specific type of organism. For example, zoology is the study of animals, while botany is the study of plants. Other life sciences focus on aspects common to all or many life forms, such as anatomy and genetics. Some focus on the micro scale (e.g. molecular biology, biochemistry) other on larger scales (e.g. cytology, immunology, ethology, ecology). Another major, branch of life sciences involves understanding the mind \u2013 neuroscience.\r\n\r\nLife sciences discoveries are helpful in improving the quality and standard of life, and have applications in health, agriculture, medicine, and the pharmaceutical and food science industries." 16 | } 17 | }, 18 | { 19 | "model": "buza.subject", 20 | "pk": 3, 21 | "fields": { 22 | "title": "Setswana", 23 | "description": "Setswana ke puo e e buiwang mo mafatsheng a Aforika Borwa, Botswana, Namibia le Zimbabwe. Ke nngwe ya dipuo tsa semmuso kwa Aforika Borwa fa kwa Botswana gone e le puo ee tsewang e le yone ya lefatshe ka bophara. Banni bangwe ba Botswana ba tsalwa ba sa bue Setswana jaaka Bakgalagadi, Bakalaka le ba bangwe. Ba se ithuta mo mebileng le kwa sekolong. Molao motheo wa Zimbabwe o moswa o kaya puo ya Setswana e le nngwe ya dipuo tsa semmuso tsa lefatshe leo." 24 | } 25 | }, 26 | { 27 | "model": "buza.subject", 28 | "pk": 4, 29 | "fields": { 30 | "title": "Economics and Management Science", 31 | "description": "Economic and Management Sciences is made up of the School of Management Sciences, the School of Economic and Financial Sciences, and the School of Public & Operations Management, The Institute For Corporate Citizenship and five Centres" 32 | } 33 | }, 34 | { 35 | "model": "buza.subject", 36 | "pk": 5, 37 | "fields": { 38 | "title": "Accounting", 39 | "description": "Accounting or accountancy is the measurement, processing, and communication of financial information about economic entities[1][2] such as businesses and corporations. The modern field was established by the Italian mathematician Luca Pacioli in 1494.[3] Accounting, which has been called the \"language of business\",[4] measures the results of an organization's economic activities and conveys this information to a variety of users, including investors, creditors, management, and regulators.[5] Practitioners of accounting are known as accountants. The terms \"accounting\" and \"financial reporting\" are often used as synonyms." 40 | } 41 | }, 42 | { 43 | "model": "buza.subject", 44 | "pk": 6, 45 | "fields": { 46 | "title": "Life Orientation", 47 | "description": "Life orientation is an excitingly diverse subject, incorporating many aspects of life. Most people who matriculated more than a decade ago, will remember life skills, guidance counselling, PT classes and religious studies. However, Life orientation (LO) has evolved into a holistic subject encompassing emotional, physical, spiritual and mental aspects of life. For example: Life orientation provides a learner with the necessary skills to compile a CV, understand relationships, find a career, learn about lifestyle diseases or understand why democracy is necessary in our country\u2026.to name but a few." 48 | } 49 | }, 50 | { 51 | "model": "buza.subject", 52 | "pk": 7, 53 | "fields": { 54 | "title": "Information Technology", 55 | "description": "Computer science is the study of the theory, experimentation, and engineering that form the basis for the design and use of computers. It is the scientific and practical approach to computation and its applications and the systematic study of the feasibility, structure, expression, and mechanization of the methodical procedures (or algorithms) that underlie the acquisition, representation, processing, storage, communication of, and access to, information. An alternate, more succinct definition of computer science is the study of automating algorithmic processes that scale. A computer scientist specializes in the theory of computation and the design of computational systems.[1] See glossary of computer science." 56 | } 57 | }, 58 | { 59 | "model": "buza.subject", 60 | "pk": 8, 61 | "fields": { 62 | "title": "Mathematics", 63 | "description": "Mathematics as \"the Queen of the Sciences\". The science of numbers and their operations, interrelations, combinations, generalizations, and abstractions and of space configurations and their structure, measurement, transformations, and generalizations\r\n\r\n Algebra, arithmetic, calculus, geometry, and trigonometry are branches of mathematics." 64 | } 65 | }, 66 | { 67 | "model": "buza.subject", 68 | "pk": 9, 69 | "fields": { 70 | "title": "Physics", 71 | "description": "Physics is the branch of science concerned with the nature and properties of matter and energy. The subject matter of physics includes mechanics, heat, light and other radiation, sound, electricity, magnetism, and the structure of atoms." 72 | } 73 | }, 74 | { 75 | "model": "buza.subject", 76 | "pk": 10, 77 | "fields": { 78 | "title": "Economics", 79 | "description": "Economics is the social science that studies the production, distribution, and consumption of goods and services.[4]\r\n\r\nEconomics focuses on the behaviour and interactions of economic agents and how economies work. Microeconomics analyzes basic elements in the economy, including individual agents and markets, their interactions, and the outcomes of interactions. Individual agents may include, for example, households, firms, buyers, and sellers. Macroeconomics analyzes the entire economy (meaning aggregated production, consumption, savings, and investment) and issues affecting it, including unemployment of resources (labour, capital, and land), inflation, economic growth, and the public policies that address these issues (monetary, fiscal, and other policies). See glossary of economics." 80 | } 81 | }, 82 | { 83 | "model": "buza.topic", 84 | "pk": 1, 85 | "fields": { 86 | "name": "", 87 | "slug": "", 88 | "description": "Trig is the study of shapes and stuff" 89 | } 90 | }, 91 | { 92 | "model": "buza.topic", 93 | "pk": 2, 94 | "fields": { 95 | "name": "maths", 96 | "slug": "maths", 97 | "description": "" 98 | } 99 | }, 100 | { 101 | "model": "buza.topic", 102 | "pk": 3, 103 | "fields": { 104 | "name": "trig", 105 | "slug": "trig", 106 | "description": "Trigonometry is a branch of mathematics that studies relationships involving lengths and angles of triangles. The field emerged in the Hellenistic world during ..." 107 | } 108 | }, 109 | { 110 | "model": "buza.topic", 111 | "pk": 4, 112 | "fields": { 113 | "name": "calculus", 114 | "slug": "calculus", 115 | "description": "Numbers and stuff" 116 | } 117 | }, 118 | { 119 | "model": "buza.topic", 120 | "pk": 5, 121 | "fields": { 122 | "name": "circles", 123 | "slug": "circles", 124 | "description": "" 125 | } 126 | }, 127 | { 128 | "model": "buza.topic", 129 | "pk": 6, 130 | "fields": { 131 | "name": "president mangope", 132 | "slug": "president-mangope", 133 | "description": "" 134 | } 135 | }, 136 | { 137 | "model": "buza.topic", 138 | "pk": 7, 139 | "fields": { 140 | "name": "girl", 141 | "slug": "girl", 142 | "description": "" 143 | } 144 | }, 145 | { 146 | "model": "buza.topic", 147 | "pk": 8, 148 | "fields": { 149 | "name": "triangles", 150 | "slug": "triangles", 151 | "description": "" 152 | } 153 | }, 154 | { 155 | "model": "buza.topic", 156 | "pk": 9, 157 | "fields": { 158 | "name": "area", 159 | "slug": "area", 160 | "description": "" 161 | } 162 | }, 163 | { 164 | "model": "buza.topic", 165 | "pk": 10, 166 | "fields": { 167 | "name": "geometry", 168 | "slug": "geometry", 169 | "description": "" 170 | } 171 | }, 172 | { 173 | "model": "buza.topic", 174 | "pk": 11, 175 | "fields": { 176 | "name": "Triangles", 177 | "slug": "triangles_1", 178 | "description": "" 179 | } 180 | }, 181 | { 182 | "model": "buza.topic", 183 | "pk": 12, 184 | "fields": { 185 | "name": "dennotation", 186 | "slug": "dennotation", 187 | "description": "" 188 | } 189 | }, 190 | { 191 | "model": "buza.topic", 192 | "pk": 13, 193 | "fields": { 194 | "name": "energy", 195 | "slug": "energy", 196 | "description": "" 197 | } 198 | }, 199 | { 200 | "model": "buza.topic", 201 | "pk": 14, 202 | "fields": { 203 | "name": "work", 204 | "slug": "work", 205 | "description": "" 206 | } 207 | }, 208 | { 209 | "model": "buza.topic", 210 | "pk": 15, 211 | "fields": { 212 | "name": "and", 213 | "slug": "and", 214 | "description": "" 215 | } 216 | }, 217 | { 218 | "model": "buza.topic", 219 | "pk": 16, 220 | "fields": { 221 | "name": "forces", 222 | "slug": "forces", 223 | "description": "" 224 | } 225 | }, 226 | { 227 | "model": "buza.topic", 228 | "pk": 17, 229 | "fields": { 230 | "name": "potential", 231 | "slug": "potential", 232 | "description": "" 233 | } 234 | }, 235 | { 236 | "model": "buza.topic", 237 | "pk": 18, 238 | "fields": { 239 | "name": "velocity", 240 | "slug": "velocity", 241 | "description": "" 242 | } 243 | }, 244 | { 245 | "model": "buza.topic", 246 | "pk": 19, 247 | "fields": { 248 | "name": "lediri", 249 | "slug": "lediri", 250 | "description": "" 251 | } 252 | }, 253 | { 254 | "model": "buza.topic", 255 | "pk": 20, 256 | "fields": { 257 | "name": "rule", 258 | "slug": "rule", 259 | "description": "" 260 | } 261 | }, 262 | { 263 | "model": "buza.topic", 264 | "pk": 21, 265 | "fields": { 266 | "name": "The", 267 | "slug": "the", 268 | "description": "" 269 | } 270 | }, 271 | { 272 | "model": "buza.topic", 273 | "pk": 22, 274 | "fields": { 275 | "name": "Trigonometric", 276 | "slug": "trigonometric", 277 | "description": "" 278 | } 279 | }, 280 | { 281 | "model": "buza.topic", 282 | "pk": 23, 283 | "fields": { 284 | "name": "poetry", 285 | "slug": "poetry", 286 | "description": "" 287 | } 288 | }, 289 | { 290 | "model": "buza.topic", 291 | "pk": 24, 292 | "fields": { 293 | "name": "poems/", 294 | "slug": "poems", 295 | "description": "" 296 | } 297 | }, 298 | { 299 | "model": "buza.questiontopic", 300 | "pk": 11, 301 | "fields": { 302 | "content_type": [ 303 | "buza", 304 | "question" 305 | ], 306 | "object_id": 4, 307 | "tag": 8 308 | } 309 | }, 310 | { 311 | "model": "buza.questiontopic", 312 | "pk": 12, 313 | "fields": { 314 | "content_type": [ 315 | "buza", 316 | "question" 317 | ], 318 | "object_id": 4, 319 | "tag": 9 320 | } 321 | }, 322 | { 323 | "model": "buza.questiontopic", 324 | "pk": 13, 325 | "fields": { 326 | "content_type": [ 327 | "buza", 328 | "question" 329 | ], 330 | "object_id": 4, 331 | "tag": 3 332 | } 333 | }, 334 | { 335 | "model": "buza.questiontopic", 336 | "pk": 14, 337 | "fields": { 338 | "content_type": [ 339 | "buza", 340 | "question" 341 | ], 342 | "object_id": 5, 343 | "tag": 10 344 | } 345 | }, 346 | { 347 | "model": "buza.questiontopic", 348 | "pk": 15, 349 | "fields": { 350 | "content_type": [ 351 | "buza", 352 | "question" 353 | ], 354 | "object_id": 6, 355 | "tag": 11 356 | } 357 | }, 358 | { 359 | "model": "buza.questiontopic", 360 | "pk": 16, 361 | "fields": { 362 | "content_type": [ 363 | "buza", 364 | "question" 365 | ], 366 | "object_id": 7, 367 | "tag": 12 368 | } 369 | }, 370 | { 371 | "model": "buza.questiontopic", 372 | "pk": 17, 373 | "fields": { 374 | "content_type": [ 375 | "buza", 376 | "question" 377 | ], 378 | "object_id": 8, 379 | "tag": 13 380 | } 381 | }, 382 | { 383 | "model": "buza.questiontopic", 384 | "pk": 18, 385 | "fields": { 386 | "content_type": [ 387 | "buza", 388 | "question" 389 | ], 390 | "object_id": 8, 391 | "tag": 14 392 | } 393 | }, 394 | { 395 | "model": "buza.questiontopic", 396 | "pk": 20, 397 | "fields": { 398 | "content_type": [ 399 | "buza", 400 | "question" 401 | ], 402 | "object_id": 9, 403 | "tag": 16 404 | } 405 | }, 406 | { 407 | "model": "buza.questiontopic", 408 | "pk": 21, 409 | "fields": { 410 | "content_type": [ 411 | "buza", 412 | "question" 413 | ], 414 | "object_id": 10, 415 | "tag": 17 416 | } 417 | }, 418 | { 419 | "model": "buza.questiontopic", 420 | "pk": 22, 421 | "fields": { 422 | "content_type": [ 423 | "buza", 424 | "question" 425 | ], 426 | "object_id": 11, 427 | "tag": 18 428 | } 429 | }, 430 | { 431 | "model": "buza.questiontopic", 432 | "pk": 23, 433 | "fields": { 434 | "content_type": [ 435 | "buza", 436 | "question" 437 | ], 438 | "object_id": 12, 439 | "tag": 19 440 | } 441 | }, 442 | { 443 | "model": "buza.questiontopic", 444 | "pk": 24, 445 | "fields": { 446 | "content_type": [ 447 | "buza", 448 | "question" 449 | ], 450 | "object_id": 13, 451 | "tag": 9 452 | } 453 | }, 454 | { 455 | "model": "buza.questiontopic", 456 | "pk": 25, 457 | "fields": { 458 | "content_type": [ 459 | "buza", 460 | "question" 461 | ], 462 | "object_id": 13, 463 | "tag": 20 464 | } 465 | }, 466 | { 467 | "model": "buza.questiontopic", 468 | "pk": 26, 469 | "fields": { 470 | "content_type": [ 471 | "buza", 472 | "question" 473 | ], 474 | "object_id": 13, 475 | "tag": 21 476 | } 477 | }, 478 | { 479 | "model": "buza.questiontopic", 480 | "pk": 27, 481 | "fields": { 482 | "content_type": [ 483 | "buza", 484 | "question" 485 | ], 486 | "object_id": 14, 487 | "tag": 22 488 | } 489 | }, 490 | { 491 | "model": "buza.questiontopic", 492 | "pk": 28, 493 | "fields": { 494 | "content_type": [ 495 | "buza", 496 | "question" 497 | ], 498 | "object_id": 15, 499 | "tag": 24 500 | } 501 | }, 502 | { 503 | "model": "buza.questiontopic", 504 | "pk": 29, 505 | "fields": { 506 | "content_type": [ 507 | "buza", 508 | "question" 509 | ], 510 | "object_id": 15, 511 | "tag": 23 512 | } 513 | }, 514 | { 515 | "model": "buza.user", 516 | "fields": { 517 | "password": "pbkdf2_sha256$120000$FUddRKJRABdr$lb2RRI3eWo16yS7QO7twK3U0oKigcPLX8FGeGD2fexY=", 518 | "last_login": null, 519 | "is_superuser": true, 520 | "username": "admin", 521 | "first_name": "", 522 | "last_name": "", 523 | "email": "admin@example.org", 524 | "is_staff": true, 525 | "is_active": true, 526 | "date_joined": "2018-09-20T18:48:57.355Z", 527 | "phone": "", 528 | "school": null, 529 | "school_address": null, 530 | "grade": 7, 531 | "photo": "", 532 | "bio": null, 533 | "groups": [], 534 | "user_permissions": [], 535 | "subjects": [] 536 | } 537 | }, 538 | { 539 | "model": "buza.user", 540 | "fields": { 541 | "password": "pbkdf2_sha256$120000$Pch3idPc5KPS$ZizlHSrPaer1lPmE8hHnTbXeUebnoaCeBOKDCifQYIQ=", 542 | "last_login": "2018-08-24T17:29:28Z", 543 | "is_superuser": false, 544 | "username": "tester0", 545 | "first_name": "", 546 | "last_name": "", 547 | "email": "", 548 | "is_staff": false, 549 | "is_active": true, 550 | "date_joined": "2018-08-19T09:33:57Z", 551 | "phone": "", 552 | "school": null, 553 | "school_address": null, 554 | "grade": 7, 555 | "photo": "", 556 | "bio": null, 557 | "groups": [], 558 | "user_permissions": [], 559 | "subjects": [ 560 | 1 561 | ] 562 | } 563 | }, 564 | { 565 | "model": "buza.user", 566 | "fields": { 567 | "password": "pbkdf2_sha256$120000$q2xi6FdRA9w9$Pc+Vv+SJvRuHRlh/g0e0Z+9/Q6vvW6jlOuu3WHLds5g=", 568 | "last_login": "2018-08-21T07:08:07.390Z", 569 | "is_superuser": false, 570 | "username": "tester1", 571 | "first_name": "", 572 | "last_name": "", 573 | "email": "", 574 | "is_staff": false, 575 | "is_active": true, 576 | "date_joined": "2018-08-21T06:26:28.824Z", 577 | "phone": "", 578 | "school": null, 579 | "school_address": null, 580 | "grade": 7, 581 | "photo": "", 582 | "bio": null, 583 | "groups": [], 584 | "user_permissions": [], 585 | "subjects": [ 586 | 3, 587 | 7, 588 | 8 589 | ] 590 | } 591 | }, 592 | { 593 | "model": "buza.user", 594 | "fields": { 595 | "password": "pbkdf2_sha256$120000$ga58DyJKQM7P$nmkzjFEcuGeDZgbR8N+kVe/RXnELnJHNDsdt/+xSWMU=", 596 | "last_login": "2018-08-21T07:15:58.144Z", 597 | "is_superuser": false, 598 | "username": "tester2", 599 | "first_name": "", 600 | "last_name": "", 601 | "email": "", 602 | "is_staff": false, 603 | "is_active": true, 604 | "date_joined": "2018-08-21T06:26:55.654Z", 605 | "phone": "", 606 | "school": null, 607 | "school_address": null, 608 | "grade": 7, 609 | "photo": "", 610 | "bio": null, 611 | "groups": [], 612 | "user_permissions": [], 613 | "subjects": [ 614 | 3 615 | ] 616 | } 617 | }, 618 | { 619 | "model": "buza.user", 620 | "fields": { 621 | "password": "pbkdf2_sha256$120000$NtuN5wiiGowo$ImMgMxDWyGbd5NlmQupXXsZxqrqzOG/Yx9N+0r1IzTI=", 622 | "last_login": "2018-08-21T07:22:15.987Z", 623 | "is_superuser": false, 624 | "username": "tester3", 625 | "first_name": "", 626 | "last_name": "", 627 | "email": "", 628 | "is_staff": false, 629 | "is_active": true, 630 | "date_joined": "2018-08-21T06:27:17.042Z", 631 | "phone": "", 632 | "school": null, 633 | "school_address": null, 634 | "grade": 7, 635 | "photo": "", 636 | "bio": null, 637 | "groups": [], 638 | "user_permissions": [], 639 | "subjects": [ 640 | 1, 641 | 6 642 | ] 643 | } 644 | }, 645 | { 646 | "model": "buza.user", 647 | "fields": { 648 | "password": "pbkdf2_sha256$120000$LmgHyF6JmsnD$W48TkRhBLD79RX9i/nBXB2KCYMlm6QTRTEKToj4et8M=", 649 | "last_login": "2018-08-21T07:33:37.125Z", 650 | "is_superuser": false, 651 | "username": "tester4", 652 | "first_name": "", 653 | "last_name": "", 654 | "email": "", 655 | "is_staff": false, 656 | "is_active": true, 657 | "date_joined": "2018-08-21T06:27:39.957Z", 658 | "phone": "", 659 | "school": null, 660 | "school_address": null, 661 | "grade": 7, 662 | "photo": "", 663 | "bio": null, 664 | "groups": [], 665 | "user_permissions": [], 666 | "subjects": [ 667 | 1 668 | ] 669 | } 670 | }, 671 | { 672 | "model": "buza.user", 673 | "fields": { 674 | "password": "pbkdf2_sha256$120000$CTiDw7WZw8ms$gcX9Dw1OKQVga4s9KfrmYwrxSXN1iWZ3fiSBXV5/VZE=", 675 | "last_login": "2018-08-22T11:10:29.771Z", 676 | "is_superuser": false, 677 | "username": "tester5", 678 | "first_name": "", 679 | "last_name": "", 680 | "email": "", 681 | "is_staff": false, 682 | "is_active": true, 683 | "date_joined": "2018-08-22T10:59:38.076Z", 684 | "phone": "", 685 | "school": null, 686 | "school_address": null, 687 | "grade": 7, 688 | "photo": "", 689 | "bio": null, 690 | "groups": [], 691 | "user_permissions": [], 692 | "subjects": [ 693 | 3, 694 | 7, 695 | 8 696 | ] 697 | } 698 | }, 699 | { 700 | "model": "buza.user", 701 | "fields": { 702 | "password": "pbkdf2_sha256$120000$fvymldkyRDij$k2Ht4KIuDfKPcR+AsuUPMK8nCHb4S1kkY+XnK5mJNWA=", 703 | "last_login": "2018-08-22T11:21:25.126Z", 704 | "is_superuser": false, 705 | "username": "tester6", 706 | "first_name": "", 707 | "last_name": "", 708 | "email": "", 709 | "is_staff": false, 710 | "is_active": true, 711 | "date_joined": "2018-08-22T10:59:58.970Z", 712 | "phone": "", 713 | "school": null, 714 | "school_address": null, 715 | "grade": 7, 716 | "photo": "", 717 | "bio": null, 718 | "groups": [], 719 | "user_permissions": [], 720 | "subjects": [ 721 | 2 722 | ] 723 | } 724 | }, 725 | { 726 | "model": "buza.user", 727 | "fields": { 728 | "password": "pbkdf2_sha256$120000$i3y6bsBxyo0R$taVDjjMcFF+RcW/9cGBG46pYMjUZPkvwqlARpfDwLTo=", 729 | "last_login": "2018-08-22T11:30:34.453Z", 730 | "is_superuser": false, 731 | "username": "tester7", 732 | "first_name": "", 733 | "last_name": "", 734 | "email": "", 735 | "is_staff": false, 736 | "is_active": true, 737 | "date_joined": "2018-08-22T11:00:20.984Z", 738 | "phone": "", 739 | "school": null, 740 | "school_address": null, 741 | "grade": 7, 742 | "photo": "", 743 | "bio": null, 744 | "groups": [], 745 | "user_permissions": [], 746 | "subjects": [ 747 | 8 748 | ] 749 | } 750 | }, 751 | { 752 | "model": "buza.user", 753 | "fields": { 754 | "password": "pbkdf2_sha256$120000$eoMSZdcvDKQ8$tEppJ4pN7eSjjApsVYTzex2DU7SkoEOFjxyGpYT6wZE=", 755 | "last_login": "2018-08-22T11:38:21.137Z", 756 | "is_superuser": false, 757 | "username": "tester8", 758 | "first_name": "", 759 | "last_name": "", 760 | "email": "", 761 | "is_staff": false, 762 | "is_active": true, 763 | "date_joined": "2018-08-22T11:00:41.087Z", 764 | "phone": "", 765 | "school": null, 766 | "school_address": null, 767 | "grade": 7, 768 | "photo": "", 769 | "bio": null, 770 | "groups": [], 771 | "user_permissions": [], 772 | "subjects": [ 773 | 1, 774 | 2 775 | ] 776 | } 777 | }, 778 | { 779 | "model": "buza.user", 780 | "fields": { 781 | "password": "pbkdf2_sha256$120000$unkx3xPkws3J$r60J6aJ89R3UXfN6FPWFSqyaqDsTy3B9P2QMnOXiZUQ=", 782 | "last_login": "2018-08-24T06:52:03.605Z", 783 | "is_superuser": false, 784 | "username": "tester9", 785 | "first_name": "", 786 | "last_name": "", 787 | "email": "", 788 | "is_staff": false, 789 | "is_active": true, 790 | "date_joined": "2018-08-24T06:49:24.806Z", 791 | "phone": "", 792 | "school": null, 793 | "school_address": null, 794 | "grade": 7, 795 | "photo": "", 796 | "bio": null, 797 | "groups": [], 798 | "user_permissions": [], 799 | "subjects": [] 800 | } 801 | }, 802 | { 803 | "model": "buza.user", 804 | "fields": { 805 | "password": "pbkdf2_sha256$120000$25NPyzuMcPoR$IPpTcVYNcbHRC3VyjLiSBdcgVOL9tAuoiBfIh3J0kmg=", 806 | "last_login": "2018-08-24T07:05:37.686Z", 807 | "is_superuser": false, 808 | "username": "tester10", 809 | "first_name": "", 810 | "last_name": "", 811 | "email": "", 812 | "is_staff": false, 813 | "is_active": true, 814 | "date_joined": "2018-08-24T06:49:55.212Z", 815 | "phone": "", 816 | "school": null, 817 | "school_address": null, 818 | "grade": 7, 819 | "photo": "", 820 | "bio": null, 821 | "groups": [], 822 | "user_permissions": [], 823 | "subjects": [ 824 | 3, 825 | 6 826 | ] 827 | } 828 | }, 829 | { 830 | "model": "buza.user", 831 | "fields": { 832 | "password": "pbkdf2_sha256$120000$Ylx0hAasVf5D$88m+zfHTwgRLF9H+/PqtXvK8Y6wxD4xFTtRAvXs2cww=", 833 | "last_login": "2018-08-24T09:17:03.531Z", 834 | "is_superuser": false, 835 | "username": "tester11", 836 | "first_name": "", 837 | "last_name": "", 838 | "email": "", 839 | "is_staff": false, 840 | "is_active": true, 841 | "date_joined": "2018-08-24T09:05:21.988Z", 842 | "phone": "", 843 | "school": null, 844 | "school_address": null, 845 | "grade": 7, 846 | "photo": "", 847 | "bio": null, 848 | "groups": [], 849 | "user_permissions": [], 850 | "subjects": [ 851 | 3, 852 | 7, 853 | 8, 854 | 9 855 | ] 856 | } 857 | }, 858 | { 859 | "model": "buza.user", 860 | "fields": { 861 | "password": "pbkdf2_sha256$120000$zGudUZAOcO9S$tC5Rpy3UWjojw+PjjYQ74hhrYAg6R8H75ay7IzOvNfs=", 862 | "last_login": "2018-08-24T09:30:10.586Z", 863 | "is_superuser": false, 864 | "username": "tester12", 865 | "first_name": "", 866 | "last_name": "", 867 | "email": "", 868 | "is_staff": false, 869 | "is_active": true, 870 | "date_joined": "2018-08-24T09:05:33.210Z", 871 | "phone": "", 872 | "school": null, 873 | "school_address": null, 874 | "grade": 7, 875 | "photo": "", 876 | "bio": null, 877 | "groups": [], 878 | "user_permissions": [], 879 | "subjects": [ 880 | 2 881 | ] 882 | } 883 | }, 884 | { 885 | "model": "buza.user", 886 | "fields": { 887 | "password": "pbkdf2_sha256$120000$eYWZDOghZwTI$lI3rFaaJeJCXYf9RKxS8nr8H7W48BNsj0JRzMC4uBbY=", 888 | "last_login": null, 889 | "is_superuser": false, 890 | "username": "tester13", 891 | "first_name": "", 892 | "last_name": "", 893 | "email": "", 894 | "is_staff": false, 895 | "is_active": true, 896 | "date_joined": "2018-08-24T09:05:49.071Z", 897 | "phone": "", 898 | "school": null, 899 | "school_address": null, 900 | "grade": 7, 901 | "photo": "", 902 | "bio": null, 903 | "groups": [], 904 | "user_permissions": [], 905 | "subjects": [] 906 | } 907 | }, 908 | { 909 | "model": "buza.question", 910 | "pk": 4, 911 | "fields": { 912 | "created": "2018-08-20T18:09:15.604Z", 913 | "modified": "2018-08-20T18:09:15.604Z", 914 | "author": [ 915 | "tester0" 916 | ], 917 | "title": "How do I calculate the area of a triangle", 918 | "body": "Given the height is 5 and base 16, how do I calculate the area of the triangle?", 919 | "subject": 8, 920 | "grade": 7 921 | } 922 | }, 923 | { 924 | "model": "buza.question", 925 | "pk": 5, 926 | "fields": { 927 | "created": "2018-08-21T07:10:21.093Z", 928 | "modified": "2018-08-21T07:10:21.093Z", 929 | "author": [ 930 | "tester1" 931 | ], 932 | "title": "how do you calculate area of a circle", 933 | "body": "how do you calculate area of a circle when given diameter of 8", 934 | "subject": 8, 935 | "grade": 7 936 | } 937 | }, 938 | { 939 | "model": "buza.question", 940 | "pk": 6, 941 | "fields": { 942 | "created": "2018-08-21T07:25:49.811Z", 943 | "modified": "2018-08-21T07:26:15.666Z", 944 | "author": [ 945 | "tester3" 946 | ], 947 | "title": "finding the area", 948 | "body": "how can i find the area of a triangle", 949 | "subject": 8, 950 | "grade": 7 951 | } 952 | }, 953 | { 954 | "model": "buza.question", 955 | "pk": 7, 956 | "fields": { 957 | "created": "2018-08-21T07:36:10.340Z", 958 | "modified": "2018-08-21T07:36:35.848Z", 959 | "author": [ 960 | "tester4" 961 | ], 962 | "title": "dennotation", 963 | "body": "define the word dennotation", 964 | "subject": 1, 965 | "grade": 7 966 | } 967 | }, 968 | { 969 | "model": "buza.question", 970 | "pk": 8, 971 | "fields": { 972 | "created": "2018-08-22T11:13:31.835Z", 973 | "modified": "2018-08-23T17:53:42.288Z", 974 | "author": [ 975 | "tester5" 976 | ], 977 | "title": "what is the defination of potential energy", 978 | "body": "what is the defination of potential energy", 979 | "subject": 9, 980 | "grade": 10 981 | } 982 | }, 983 | { 984 | "model": "buza.question", 985 | "pk": 9, 986 | "fields": { 987 | "created": "2018-08-22T11:27:20.290Z", 988 | "modified": "2018-08-23T17:53:31.160Z", 989 | "author": [ 990 | "tester6" 991 | ], 992 | "title": "what is gravitational force", 993 | "body": "the definition of gravitational force", 994 | "subject": 9, 995 | "grade": 10 996 | } 997 | }, 998 | { 999 | "model": "buza.question", 1000 | "pk": 10, 1001 | "fields": { 1002 | "created": "2018-08-22T11:34:31.907Z", 1003 | "modified": "2018-08-23T17:53:22.357Z", 1004 | "author": [ 1005 | "tester7" 1006 | ], 1007 | "title": "the formula of the gravitation force", 1008 | "body": "", 1009 | "subject": 9, 1010 | "grade": 10 1011 | } 1012 | }, 1013 | { 1014 | "model": "buza.question", 1015 | "pk": 11, 1016 | "fields": { 1017 | "created": "2018-08-22T11:46:38.980Z", 1018 | "modified": "2018-08-23T17:53:02.755Z", 1019 | "author": [ 1020 | "tester8" 1021 | ], 1022 | "title": "how do you calculate velocity", 1023 | "body": "how do you calculate velocity", 1024 | "subject": 9, 1025 | "grade": 10 1026 | } 1027 | }, 1028 | { 1029 | "model": "buza.question", 1030 | "pk": 12, 1031 | "fields": { 1032 | "created": "2018-08-23T18:02:04.896Z", 1033 | "modified": "2018-08-23T18:02:04.896Z", 1034 | "author": [ 1035 | "tester0" 1036 | ], 1037 | "title": "lediri ke eng", 1038 | "body": "mme o jesa ngwana", 1039 | "subject": 3, 1040 | "grade": 8 1041 | } 1042 | }, 1043 | { 1044 | "model": "buza.question", 1045 | "pk": 13, 1046 | "fields": { 1047 | "created": "2018-08-24T07:03:26.578Z", 1048 | "modified": "2018-08-24T07:03:26.578Z", 1049 | "author": [ 1050 | "tester9" 1051 | ], 1052 | "title": "What is the fomuler for distance?", 1053 | "body": "What is the fomuler for distance?", 1054 | "subject": 8, 1055 | "grade": 10 1056 | } 1057 | }, 1058 | { 1059 | "model": "buza.question", 1060 | "pk": 14, 1061 | "fields": { 1062 | "created": "2018-08-24T07:11:59.418Z", 1063 | "modified": "2018-08-24T07:11:59.418Z", 1064 | "author": [ 1065 | "tester10" 1066 | ], 1067 | "title": "how do you keep up with changing school topics", 1068 | "body": "goreng ga o tlhaloganya topic mo maths e be morutabana a fetola kgotsa a tla ka topic e e ntshwa?", 1069 | "subject": 8, 1070 | "grade": 11 1071 | } 1072 | }, 1073 | { 1074 | "model": "buza.question", 1075 | "pk": 15, 1076 | "fields": { 1077 | "created": "2018-08-24T09:26:28.140Z", 1078 | "modified": "2018-08-24T09:26:46.895Z", 1079 | "author": [ 1080 | "tester11" 1081 | ], 1082 | "title": "how to master poetry tests?", 1083 | "body": "what should i do to understand poetry test and the question", 1084 | "subject": 1, 1085 | "grade": 12 1086 | } 1087 | }, 1088 | { 1089 | "model": "buza.answer", 1090 | "pk": 3, 1091 | "fields": { 1092 | "created": "2018-08-20T18:11:53.164Z", 1093 | "modified": "2018-08-20T18:11:53.164Z", 1094 | "author": [ 1095 | "tester0" 1096 | ], 1097 | "question": 4, 1098 | "body": "The area of a triangle is the 1/2(heightXbase)\r\nSo in your problem that would be 1/2(5*16) which is 80.\r\nI hope that helps" 1099 | } 1100 | }, 1101 | { 1102 | "model": "buza.answer", 1103 | "pk": 4, 1104 | "fields": { 1105 | "created": "2018-08-21T07:11:45.306Z", 1106 | "modified": "2018-08-21T07:12:27.797Z", 1107 | "author": [ 1108 | "tester1" 1109 | ], 1110 | "question": 4, 1111 | "body": "1/2(base*height)" 1112 | } 1113 | }, 1114 | { 1115 | "model": "buza.answer", 1116 | "pk": 5, 1117 | "fields": { 1118 | "created": "2018-08-21T07:17:55.917Z", 1119 | "modified": "2018-08-21T07:17:55.918Z", 1120 | "author": [ 1121 | "tester2" 1122 | ], 1123 | "question": 5, 1124 | "body": "3.14*4^2" 1125 | } 1126 | }, 1127 | { 1128 | "model": "buza.answer", 1129 | "pk": 6, 1130 | "fields": { 1131 | "created": "2018-08-21T07:28:56.610Z", 1132 | "modified": "2018-08-21T07:28:56.610Z", 1133 | "author": [ 1134 | "tester3" 1135 | ], 1136 | "question": 5, 1137 | "body": "3.14*8=25.12" 1138 | } 1139 | }, 1140 | { 1141 | "model": "buza.answer", 1142 | "pk": 7, 1143 | "fields": { 1144 | "created": "2018-08-22T11:18:27.473Z", 1145 | "modified": "2018-08-22T11:18:27.473Z", 1146 | "author": [ 1147 | "tester5" 1148 | ], 1149 | "question": 6, 1150 | "body": "1/2(base)*height" 1151 | } 1152 | }, 1153 | { 1154 | "model": "buza.answer", 1155 | "pk": 8, 1156 | "fields": { 1157 | "created": "2018-08-22T11:24:09.730Z", 1158 | "modified": "2018-08-22T11:24:26.786Z", 1159 | "author": [ 1160 | "tester6" 1161 | ], 1162 | "question": 8, 1163 | "body": "it is the energy stored in an object" 1164 | } 1165 | }, 1166 | { 1167 | "model": "buza.answer", 1168 | "pk": 9, 1169 | "fields": { 1170 | "created": "2018-08-22T11:35:48.932Z", 1171 | "modified": "2018-08-22T11:35:48.932Z", 1172 | "author": [ 1173 | "tester7" 1174 | ], 1175 | "question": 6, 1176 | "body": "1/2 base*height" 1177 | } 1178 | }, 1179 | { 1180 | "model": "buza.answer", 1181 | "pk": 10, 1182 | "fields": { 1183 | "created": "2018-08-22T11:41:13.718Z", 1184 | "modified": "2018-08-22T11:41:13.718Z", 1185 | "author": [ 1186 | "tester8" 1187 | ], 1188 | "question": 4, 1189 | "body": "1/2*(5)*(16)" 1190 | } 1191 | }, 1192 | { 1193 | "model": "buza.answer", 1194 | "pk": 11, 1195 | "fields": { 1196 | "created": "2018-08-24T06:57:01.066Z", 1197 | "modified": "2018-08-24T06:57:01.066Z", 1198 | "author": [ 1199 | "tester9" 1200 | ], 1201 | "question": 7, 1202 | "body": "dictionary meaning.The actual meaning of the word." 1203 | } 1204 | }, 1205 | { 1206 | "model": "buza.answer", 1207 | "pk": 12, 1208 | "fields": { 1209 | "created": "2018-08-24T09:20:25.886Z", 1210 | "modified": "2018-08-24T09:20:25.886Z", 1211 | "author": [ 1212 | "tester11" 1213 | ], 1214 | "question": 12, 1215 | "body": "lediri ke lefoko le le dirang tiro\r\nin the sentence \"mme o jesa ngwana\"\r\nlediri \" jesa\"\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n'" 1216 | } 1217 | }, 1218 | { 1219 | "model": "buza.answer", 1220 | "pk": 13, 1221 | "fields": { 1222 | "created": "2018-08-24T09:34:19.994Z", 1223 | "modified": "2018-08-24T09:34:19.994Z", 1224 | "author": [ 1225 | "tester12" 1226 | ], 1227 | "question": 14, 1228 | "body": "The thing is that teachers are there to keep their school learners up to date with the material the students need in order to be able to pass that particular date.So,you can make things easier for yourself by making sure that when ever you feel like you are getting lost in a topic,you actually go and ask for help from any person you feel comfortable asking questions with.Therefore it will be easy to inter-change between different topics." 1229 | } 1230 | } 1231 | ] 1232 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.forms import ModelForm 4 | from django.http import HttpResponse 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | 8 | from buza import models 9 | 10 | 11 | class TestRegister(TestCase): 12 | """ 13 | The `register` view should create users and log them in. 14 | """ 15 | def setUp(self) -> None: 16 | self.path = reverse('register') 17 | 18 | def test_get(self) -> None: 19 | response = self.client.get(self.path) 20 | assert HTTPStatus.OK == response.status_code 21 | self.assertTemplateUsed(response, 'accounts/register.html') 22 | assert 'user_form' in response.context 23 | 24 | def test_get__authenticated(self) -> None: 25 | user: models.User = models.User.objects.create() 26 | self.client.force_login(user) 27 | response = self.client.get(self.path) 28 | assert HTTPStatus.OK == response.status_code 29 | self.assertTemplateUsed(response, 'accounts/register.html') 30 | assert 'user_form' in response.context 31 | 32 | def test_post__empty(self) -> None: 33 | """ 34 | Test that when user submits an empty register from, the user is not created. 35 | """ 36 | response: HttpResponse = self.client.post(self.path) 37 | assert HTTPStatus.OK == response.status_code 38 | assert self.assertTemplateUsed('accounts/register.html') 39 | 40 | form: ModelForm = response.context['user_form'] # noqa: E701 41 | assert [] == form.non_field_errors() 42 | assert { 43 | 'username': ['This field is required.'], 44 | 'password1': ['This field is required.'], 45 | 'password2': ['This field is required.'], 46 | } == form.errors 47 | assert not form.is_valid() 48 | 49 | def test_post__passwords_mismatch(self) -> None: 50 | response: HttpResponse = self.client.post(self.path, data=dict( 51 | username='buza-user-12', 52 | password1='password', 53 | password2='mismatch', 54 | )) 55 | assert HTTPStatus.OK == response.status_code 56 | assert self.assertTemplateUsed('accounts/register.html') 57 | 58 | form: ModelForm = response.context['user_form'] # noqa: E701 59 | assert [] == form.non_field_errors() 60 | assert { 61 | 'password2': ["The two password fields didn't match."], 62 | } == form.errors 63 | assert not form.is_valid() 64 | 65 | def test_post__valid_form(self) -> None: 66 | response = self.client.post(self.path, data=dict( 67 | username='buza-user-12', 68 | password1='secret', 69 | password2='secret', 70 | )) 71 | assert HTTPStatus.OK == response.status_code 72 | self.assertTemplateUsed('accounts/register_done.html') 73 | new_user: models.User = models.User.objects.get() 74 | assert new_user == response.context['new_user'] 75 | 76 | assert { 77 | 'bio': None, 78 | 'date_joined': new_user.date_joined, 79 | 'email': '', 80 | 'first_name': '', 81 | 'grade': 7, 82 | 'id': new_user.pk, 83 | 'is_active': True, 84 | 'is_staff': False, 85 | 'is_superuser': False, 86 | 'last_login': None, 87 | 'last_name': '', 88 | 'password': new_user.password, 89 | 'phone': '', 90 | 'photo': '', 91 | 'school': None, 92 | 'school_address': None, 93 | 'username': 'buza-user-12', 94 | } == models.User.objects.filter(pk=new_user.pk).values().get() 95 | 96 | 97 | class TestUserUpdate(TestCase): 98 | 99 | def _authenticated_user(self) -> models.User: 100 | """ 101 | Create and return an authenticated user. 102 | """ 103 | user: models.User = models.User.objects.create() 104 | self.client.force_login(user) 105 | return user 106 | 107 | def test_get__anonymous(self) -> None: 108 | user: models.User = models.User.objects.create() 109 | response = self.client.get(reverse('user-update', kwargs=dict(pk=user.pk))) 110 | self.assertRedirects(response, f'/auth/login/?next=/users/{user.pk}/update/') 111 | 112 | def test_post__anonymous(self) -> None: 113 | user: models.User = models.User.objects.create() 114 | response = self.client.post(reverse('user-update', kwargs=dict(pk=user.pk))) 115 | self.assertRedirects(response, f'/auth/login/?next=/users/{user.pk}/update/') 116 | 117 | def test_get__authenticated(self) -> None: 118 | user = self._authenticated_user() 119 | response = self.client.get(reverse('user-update', kwargs=dict(pk=user.pk))) 120 | assert HTTPStatus.OK == response.status_code 121 | self.assertTemplateUsed(response, 'accounts/edit.html') 122 | 123 | assert 'form' in response.context 124 | form: ModelForm = response.context['form'] # noqa: E701 125 | assert user == form.instance 126 | assert not form.is_bound 127 | 128 | def test_post__empty(self) -> None: 129 | user = self._authenticated_user() 130 | response = self.client.post(reverse('user-update', kwargs=dict(pk=user.pk))) 131 | self.assertRedirects(response, reverse('user-detail', kwargs=dict(pk=user.pk))) 132 | 133 | def test_post__blank(self) -> None: 134 | user = self._authenticated_user() 135 | response = self.client.post( 136 | path=reverse('user-update', kwargs=dict(pk=user.pk)), 137 | data={ 138 | 'email': '', 139 | 'phone': '', 140 | 'photo': '', 141 | 'first_name': '', 142 | 'last_name': '', 143 | 'school': '', 144 | 'school_address': '', 145 | 'grade': '', 146 | 'bio': '', 147 | }, 148 | ) 149 | self.assertRedirects(response, reverse('user-detail', kwargs=dict(pk=user.pk))) 150 | 151 | 152 | class TestUserDetail(TestCase): 153 | 154 | def test_not_found(self) -> None: 155 | response = self.client.get(reverse('user-detail', kwargs=dict(pk=404))) 156 | assert HTTPStatus.NOT_FOUND == response.status_code 157 | 158 | def test_get(self) -> None: 159 | user = models.User.objects.create( 160 | first_name='Test', 161 | last_name='User', 162 | photo='example.jpeg', 163 | email='tester@example.com', 164 | bio='Example bio.', 165 | ) 166 | response = self.client.get(reverse('user-detail', kwargs=dict(pk=user.pk))) 167 | assert HTTPStatus.OK == response.status_code 168 | self.assertTemplateUsed(response, 'buza/user_detail.html') 169 | 170 | self.assertContains(response, 'Test User', count=2) 171 | self.assertContains(response, 'Example bio.', count=1) 172 | 173 | def test_get__user_with_question(self) -> None: 174 | user = models.User.objects.create( 175 | first_name='Test', 176 | last_name='User', 177 | username='newuser', 178 | email='tester@example.com', 179 | bio='Example bio.', 180 | ) 181 | subject: models.Subject = models.Subject.objects.create(title="maths") 182 | question = models.Question.objects.create( 183 | author=user, 184 | title='Example question?', 185 | body='A question.', 186 | subject=subject, 187 | grade=7, 188 | ) 189 | response = self.client.get(reverse('user-detail', kwargs=dict(pk=user.pk))) 190 | assert HTTPStatus.OK == response.status_code 191 | self.assertTemplateUsed(response, 'buza/user_detail.html') 192 | 193 | self.assertContains(response, user.get_full_name(), count=2) 194 | self.assertContains(response, "@" + user.username, count=2) 195 | self.assertContains(response, user.bio, count=1) 196 | self.assertContains(response, question.title, count=1) 197 | self.assertContains(response, question.subject, count=1) 198 | 199 | 200 | class TestQuestionDetail(TestCase): 201 | 202 | def test_not_found(self) -> None: 203 | response = self.client.get(reverse('user-detail', kwargs=dict(pk=404))) 204 | assert HTTPStatus.NOT_FOUND == response.status_code 205 | 206 | def test_get(self) -> None: 207 | user = models.User.objects.create(username="username") 208 | user2 = models.User.objects.create(username="username2") 209 | subject: models.Subject = models.Subject.objects.create(title="maths") 210 | question = models.Question.objects.create( 211 | author=user, 212 | title='Example question?', 213 | body='A question.', 214 | subject=subject, 215 | grade=7, 216 | ) 217 | answer: models.Answer = models.Answer.objects.create( 218 | body='An answer', 219 | question=question, 220 | author=user2, 221 | ) 222 | path = reverse('question-detail', kwargs=dict(pk=question.pk)) 223 | response = self.client.get(path) 224 | assert HTTPStatus.OK == response.status_code 225 | self.assertTemplateUsed(response, 'buza/question_detail.html') 226 | 227 | assert question == response.context['question'] 228 | self.assertContains(response, question.title, count=2) 229 | self.assertContains(response, question.body, count=0) 230 | self.assertContains(response, subject.title, count=1) 231 | self.assertContains(response, answer.author, count=1) 232 | self.assertContains(response, "now", count=2) 233 | self.assertContains(response, "answers") 234 | self.assertNotContains(response, "edit answer") 235 | 236 | def test_get__authenticated(self) -> None: 237 | user = models.User.objects.create(username="username") 238 | subject: models.Subject = models.Subject.objects.create(title="maths") 239 | self.client.force_login(user) 240 | question = models.Question.objects.create( 241 | author=user, 242 | title='Example question?', 243 | body='A question.', 244 | subject=subject, 245 | grade=7, 246 | ) 247 | path = reverse('question-detail', kwargs=dict(pk=question.pk)) 248 | response = self.client.get(path) 249 | assert HTTPStatus.OK == response.status_code 250 | self.assertTemplateUsed(response, 'buza/question_detail.html') 251 | 252 | assert question == response.context['question'] 253 | self.assertContains(response, question.title, count=2) 254 | self.assertContains(response, question.body, count=0) 255 | self.assertContains(response, subject.title, count=1) 256 | self.assertContains(response, "now", count=1) 257 | self.assertContains(response, "answers") 258 | print(response.content) 259 | self.assertContains(response, "Edit question") 260 | self.assertContains(response, "No answers yet") 261 | 262 | 263 | class TestQuestionList(TestCase): 264 | def setUp(self) -> None: 265 | self.user: models.User = models.User.objects.create() 266 | self.maths: models.Subject = models.Subject.objects.create( 267 | title='Mathematics', 268 | short_title='maths', 269 | ) 270 | self.biology: models.Subject = models.Subject.objects.create( 271 | title='Biology', 272 | short_title='bio', 273 | ) 274 | self.question: models.Question = models.Question.objects.create( 275 | title="this is a queston", 276 | author=self.user, 277 | subject=self.maths, 278 | grade=7, 279 | ) 280 | self.answer: models.Answer = models.Answer.objects.create( 281 | question=self.question, 282 | author=self.user, 283 | ) 284 | self.path = reverse('question-list') 285 | 286 | def test_get__one_question(self) -> None: 287 | """ 288 | Test Question list view with one question 289 | """ 290 | response = self.client.get(reverse('question-list')) 291 | assert HTTPStatus.OK == response.status_code 292 | self.assertTemplateUsed(response, 'buza/question_list.html') 293 | 294 | self.assertNotContains(response, "Follow") 295 | self.assertNotContains(response, "Following") 296 | self.assertQuerysetEqual(response.context['subject_list'], [ 297 | '', 298 | '', 299 | ]) 300 | self.assertContains(response, self.maths.title, count=2) 301 | self.assertContains(response, self.biology.title, count=1) 302 | self.assertContains(response, "@" + self.user.username) 303 | self.assertContains(response, self.question.title) 304 | self.assertContains(response, "now", count=1) 305 | self.assertContains(response, self.answer.body) 306 | 307 | def test_get__unauthenticated(self) -> None: 308 | """ 309 | Unauthenticated users can view but not follow subjects 310 | :return: 311 | """ 312 | response = self.client.get(self.path) 313 | assert HTTPStatus.OK == response.status_code 314 | self.assertNotContains(response, "Follow") 315 | self.assertNotContains(response, "Following") 316 | self.assertContains(response, self.maths.title, count=2) 317 | self.assertContains(response, self.biology.title, count=1) 318 | self.assertContains(response, "@" + self.user.username) 319 | self.assertContains(response, self.answer.body) 320 | # test the humanizer 321 | self.assertContains(response, "now") 322 | 323 | # Listed by title. 324 | self.assertQuerysetEqual(response.context['subject_list'], [ 325 | '', 326 | '', 327 | ]) 328 | 329 | def test_get__no_followed_subjects(self) -> None: 330 | """ 331 | Logged in users can view the list of subjects and follow them 332 | """ 333 | self.client.force_login(self.user) 334 | response = self.client.get(self.path) 335 | assert HTTPStatus.OK == response.status_code 336 | self.assertContains(response, "follow") 337 | self.assertContains(response, "★") 338 | self.assertContains(response, "following", 0) 339 | self.assertContains(response, self.maths.title, count=2) 340 | self.assertContains(response, self.biology.title, count=1) 341 | self.assertContains(response, "@" + self.user.username) 342 | self.assertContains(response, self.question.title) 343 | self.assertContains(response, "now") 344 | self.assertContains(response, self.answer.body) 345 | 346 | # Listed by title. 347 | self.assertQuerysetEqual(response.context['subject_list'], [ 348 | '', 349 | '', 350 | ]) 351 | 352 | def test_get__followed_subjects(self) -> None: 353 | """ 354 | When follow a question, the UI updates 355 | :return: 356 | """ 357 | self.client.force_login(self.user) 358 | self.user.subjects.add(self.maths) 359 | response = self.client.get(self.path) 360 | self.assertTemplateUsed(response, 'buza/question_list.html') 361 | assert HTTPStatus.OK == response.status_code 362 | self.assertContains(response, "following") 363 | self.assertContains(response, "follow") 364 | self.assertContains(response, "@" + self.user.username) 365 | self.assertContains(response, self.question.title) 366 | self.assertContains(response, "now") 367 | self.assertContains(response, self.answer.body) 368 | # Maths (followed) listed first. 369 | self.assertQuerysetEqual(response.context['subject_list'], [ 370 | '', 371 | '', 372 | ]) 373 | 374 | def test_get__long_subject_names(self) -> None: 375 | """ 376 | When follow a question, the UI updates 377 | :return: 378 | """ 379 | self.client.force_login(self.user) 380 | self.user.subjects.add(self.maths) 381 | 382 | ems: models.Subject = models.Subject.objects.create( 383 | title='Economics and Management Sciences', 384 | short_title='EMS', 385 | ) 386 | response = self.client.get(self.path) 387 | self.assertTemplateUsed(response, 'buza/question_list.html') 388 | assert HTTPStatus.OK == response.status_code 389 | # test that EMS is truncated 390 | self.assertNotContains(response, ems.title) 391 | self.assertContains(response, 'Economics and Manage...') 392 | 393 | # regular question list tests 394 | self.assertContains(response, "@" + self.user.username) 395 | self.assertContains(response, self.question.title) 396 | self.assertContains(response, "now") 397 | 398 | def test_post_unauthenticated(self) -> None: 399 | """Redirect unauthenticated user's posts """ 400 | 401 | response: HttpResponse = self.client.post(self.path, data={ 402 | 'follow-subject': self.maths.pk, 403 | }) 404 | 405 | assert HTTPStatus.FOUND == response.status_code 406 | self.assertRedirects(response, f'/auth/login/?next=/') 407 | 408 | def test_post__follow_subject(self) -> None: 409 | """Redirect unauthenticated user's posts """ 410 | self.client.force_login(self.user) 411 | path = reverse('subject-detail', kwargs=dict(pk=self.maths.pk)) 412 | response: HttpResponse = self.client.post(path, data={ 413 | 'follow-subject': self.maths.pk, 414 | }) 415 | 416 | assert HTTPStatus.FOUND == response.status_code 417 | self.assertRedirects(response, f'/subjects/{self.maths.pk}/') 418 | self.assertEqual(self.user.subjects.all().count(), 1) 419 | 420 | response = self.client.get(response.url) 421 | self.assertContains(response, "following") 422 | self.assertContains(response, "follow") 423 | # Maths (followed) listed first. 424 | self.assertQuerysetEqual(response.context['subject_list'], [ 425 | '', 426 | '', 427 | ]) 428 | 429 | self.assertContains(response, "@" + self.user.username) 430 | self.assertContains(response, self.question.title) 431 | self.assertContains(response, "now") 432 | self.assertContains(response, self.answer.body) 433 | 434 | def test_post__unfollow_in_subjectlist(self) -> None: 435 | """Redirect unauthenticated user's posts """ 436 | self.client.force_login(self.user) 437 | 438 | # follow and unfollow a subject 439 | path = reverse('subject-detail', kwargs=dict(pk=self.maths.pk)) 440 | self.client.post(path, data={ 441 | 'follow-subject': self.maths.pk, 442 | 443 | }) 444 | response: HttpResponse = self.client.post(self.path, data={ 445 | 'following-subject': self.maths.pk, 446 | 447 | }) 448 | 449 | assert HTTPStatus.FOUND == response.status_code 450 | self.assertRedirects(response, f'/subjects/{self.maths.pk}/') 451 | self.assertEqual(self.user.subjects.all().count(), 0) 452 | 453 | response = self.client.get(response.url) 454 | self.assertNotContains(response, "following") 455 | self.assertContains(response, "follow") 456 | # Maths (followed) listed first. 457 | self.assertQuerysetEqual(response.context['subject_list'], [ 458 | '', 459 | '', 460 | ]) 461 | 462 | self.assertContains(response, "@" + self.user.username) 463 | self.assertContains(response, self.question.title) 464 | self.assertContains(response, "now") 465 | self.assertContains(response, self.answer.body) 466 | 467 | 468 | class TestQuestionCreate(TestCase): 469 | 470 | def setUp(self) -> None: 471 | self.subject: models.Subject = models.Subject.objects.create( 472 | title='mathematics', 473 | short_title='maths', 474 | ) 475 | self.user: models.User = models.User.objects.create() 476 | 477 | def test_get__anonymous(self) -> None: 478 | response = self.client.get(reverse( 479 | 'question-create', 480 | kwargs=dict(subject_pk=self.subject.pk), 481 | )) 482 | self.assertRedirects( 483 | response, 484 | f'/auth/login/?next=/questions/{self.subject.pk}/ask/', 485 | ) 486 | 487 | def test_post__anonymous(self) -> None: 488 | response = self.client.post(reverse( 489 | 'question-create', 490 | kwargs=dict(subject_pk=self.subject.pk), 491 | )) 492 | self.assertRedirects( 493 | response, 494 | f'/auth/login/?next=/questions/{self.subject.pk}/ask/', 495 | ) 496 | 497 | def test_get__authenticated(self) -> None: 498 | self.client.force_login(self.user) 499 | response = self.client.get(reverse( 500 | 'question-create', 501 | kwargs=dict(subject_pk=self.subject.pk), 502 | )) 503 | assert HTTPStatus.OK == response.status_code 504 | self.assertTemplateUsed(response, 'buza/question_form.html') 505 | self.assertContains(response, 'Question Summary', count=1) 506 | self.assertContains( 507 | response, 508 | 'Give a detailed description of your question', 509 | count=1, 510 | ) 511 | 512 | def test_post__empty(self) -> None: 513 | self.client.force_login(self.user) 514 | response = self.client.post(reverse( 515 | 'question-create', 516 | kwargs=dict(subject_pk=self.subject.pk), 517 | )) 518 | assert HTTPStatus.OK == response.status_code 519 | 520 | assert 'form' in response.context 521 | form: ModelForm = response.context['form'] # noqa: E701 522 | assert [] == form.non_field_errors() 523 | assert { 524 | 'title': ['This field is required.'], 525 | } == form.errors 526 | assert not form.is_valid() 527 | 528 | def test_post__success(self) -> None: 529 | """ 530 | Question post redirects to question view 531 | """ 532 | self.client.force_login(self.user) 533 | response = self.client.post(reverse( 534 | 'question-create', 535 | kwargs=dict(subject_pk=self.subject.pk)), 536 | data=dict( 537 | title='This is a title', 538 | body='This is a body', 539 | grade=7, 540 | )) 541 | question: models.Question = models.Question.objects.get() 542 | assert { 543 | 'author_id': self.user.pk, 544 | 'body': 'This is a body', 545 | 'created': question.created, 546 | 'id': question.pk, 547 | 'modified': question.modified, 548 | 'title': 'This is a title', 549 | 'subject_id': self.subject.pk, 550 | 'grade': question.grade, 551 | } == models.Question.objects.filter(pk=question.pk).values().get() 552 | self.assertRedirects(response, f'/questions/{question.pk}/') 553 | 554 | 555 | class TestQuestionUpdate(TestCase): 556 | def setUp(self) -> None: 557 | super().setUp() 558 | self.author = models.User.objects.create(username='author') 559 | self.subject: models.Subject = models.Subject.objects.create(title="maths") 560 | self.other_user = models.User.objects.create(username='otheruser') 561 | self.question = models.Question.objects.create( 562 | author=self.author, 563 | title='question', 564 | subject=self.subject, 565 | grade=7, 566 | ) 567 | 568 | def test_get__anonymous(self) -> None: 569 | response = self.client.get( 570 | reverse('question-update', kwargs=dict(pk=self.question.pk)), 571 | ) 572 | self.assertRedirects( 573 | response, 574 | f'/auth/login/?next=/questions/{self.question.pk}/edit/', 575 | ) 576 | 577 | def test_post__anonymous(self) -> None: 578 | response = self.client.get( 579 | reverse('question-update', kwargs=dict(pk=self.question.pk)), 580 | ) 581 | self.assertRedirects( 582 | response, 583 | f'/auth/login/?next=/questions/{self.question.pk}/edit/', 584 | ) 585 | 586 | def test_get__not_author(self) -> None: 587 | """ 588 | Users can only edit questions they own 589 | """ 590 | self.client.force_login(self.other_user) 591 | response = self.client.get( 592 | reverse('question-update', kwargs=dict(pk=self.question.pk)), 593 | ) 594 | assert HTTPStatus.FORBIDDEN == response.status_code 595 | 596 | def test_post__not_author(self) -> None: 597 | """ 598 | Only authors can post questions changes 599 | """ 600 | self.client.force_login(self.other_user) 601 | response = self.client.post(reverse( 602 | 'question-update', 603 | kwargs=dict(pk=self.question.pk)), data=dict( 604 | title='This is a title updated', 605 | body='This is an updated body', 606 | )) 607 | assert HTTPStatus.FORBIDDEN == response.status_code 608 | 609 | def test_post__author_update(self) -> None: 610 | """ 611 | Question update allows author to update the question 612 | 613 | """ 614 | self.client.force_login(self.author) 615 | path = reverse('question-update', kwargs=dict(pk=self.question.pk)) 616 | response = self.client.post(path, data=dict( 617 | title='This is a title updated', 618 | body='This is an updated body', 619 | subject=self.subject.pk, 620 | grade=7, 621 | )) 622 | 623 | question: models.Question = models.Question.objects.get() 624 | assert { 625 | 'author_id': self.author.pk, 626 | 'body': 'This is an updated body', 627 | 'created': question.created, 628 | 'id': question.pk, 629 | 'modified': question.modified, 630 | 'title': 'This is a title updated', 631 | 'subject_id': question.subject.pk, 632 | 'grade': question.grade, 633 | } == models.Question.objects.filter(pk=question.pk).values().get() 634 | self.assertRedirects(response, f'/questions/{self.question.pk}/') 635 | 636 | 637 | class TestAnswerCreate(TestCase): 638 | 639 | def setUp(self) -> None: 640 | super().setUp() 641 | self.user = models.User.objects.create() 642 | self.subject: models.Subject = models.Subject.objects.create(title="maths") 643 | self.question = models.Question.objects.create( 644 | author=self.user, 645 | title='question', 646 | subject=self.subject, 647 | grade=7, 648 | ) 649 | self.path = reverse('answer-create', kwargs=dict(question_pk=self.question.pk)) 650 | 651 | def test__not_found(self) -> None: 652 | path = reverse('answer-create', kwargs=dict(question_pk=404)) 653 | # Anonymous: 654 | assert HTTPStatus.NOT_FOUND == self.client.get(path).status_code 655 | assert HTTPStatus.NOT_FOUND == self.client.post(path).status_code 656 | # Authenticated: 657 | self.client.force_login(self.user) 658 | assert HTTPStatus.NOT_FOUND == self.client.get(path).status_code 659 | assert HTTPStatus.NOT_FOUND == self.client.post(path).status_code 660 | 661 | def test___anonymous(self) -> None: 662 | """ 663 | Test that when an unauthenticated user tries to answer a question 664 | they are redirected to the home page 665 | """ 666 | expected_url = f'/auth/login/?next=/questions/{self.question.pk}/answer/' 667 | self.assertRedirects(self.client.get(self.path), expected_url) 668 | self.assertRedirects(self.client.post(self.path), expected_url) 669 | 670 | def test_get__authenticated(self) -> None: 671 | self.client.force_login(self.user) 672 | response: HttpResponse = self.client.get(self.path) 673 | assert HTTPStatus.OK == response.status_code 674 | assert self.assertTemplateUsed('buza/question_form.html') 675 | assert self.question == response.context['question'] 676 | 677 | def test_post__empty(self) -> None: 678 | """ 679 | Test that when an authenticated user submits an empty answer 680 | the answer is not posted 681 | """ 682 | self.client.force_login(self.user) 683 | response: HttpResponse = self.client.post(self.path) 684 | assert HTTPStatus.OK == response.status_code 685 | assert self.assertTemplateUsed('buza/question_form.html') 686 | assert self.question == response.context['question'] 687 | 688 | form: ModelForm = response.context['form'] # noqa: E701 689 | assert [] == form.non_field_errors() 690 | assert {'body': ['This field is required.']} == form.errors 691 | assert not form.is_valid() 692 | 693 | def test_post__valid(self) -> None: 694 | """ 695 | Test that when an authenticated user submits a valid answer 696 | the answer is posted 697 | """ 698 | self.client.force_login(self.user) 699 | response: HttpResponse = self.client.post(self.path, data={ 700 | 'body': 'An example answer', 701 | }) 702 | answer: models.Answer = models.Answer.objects.get() 703 | assert { 704 | 'author_id': self.user.pk, 705 | 'created': answer.created, 706 | 'id': answer.pk, 707 | 'modified': answer.modified, 708 | 'body': 'An example answer', 709 | 'question_id': 1, 710 | } == models.Answer.objects.filter(pk=answer.pk).values().get() 711 | self.assertRedirects(response, f'/questions/{answer.question.pk}/') 712 | 713 | 714 | class TestAnswerUpdate(TestCase): 715 | def setUp(self) -> None: 716 | super().setUp() 717 | self.author: models.User = models.User.objects.create() 718 | self.answer_author: models.User = \ 719 | models.User.objects.create(username='answer_author') 720 | self.subject: models.Subject = models.Subject.objects.create(title="maths") 721 | self.question: models.Question = models.Question.objects.create( 722 | author=self.author, 723 | title='question', 724 | subject=self.subject, 725 | grade=12, 726 | ) 727 | self.answer: models.Answer = models.Answer.objects.create( 728 | author=self.answer_author, 729 | body='This is an answer', 730 | question=self.question, 731 | ) 732 | self.path = reverse('answer-update', kwargs=dict(pk=self.answer.pk)) 733 | 734 | def test_get__anonymous(self) -> None: 735 | response = self.client.get(self.path) 736 | self.assertRedirects( 737 | response, 738 | f'/auth/login/?next=/answers/{self.answer.pk}/edit/', 739 | ) 740 | 741 | def test_get__authenticated(self) -> None: 742 | self.client.force_login(self.answer_author) 743 | response = self.client.get(self.path) 744 | self.assertTemplateUsed(response, 'buza/answer_form.html') 745 | assert self.question == response.context['question'] 746 | assert self.answer == response.context['answer'] 747 | 748 | def test_post__anonymous(self) -> None: 749 | response = self.client.post(self.path) 750 | self.assertRedirects( 751 | response, 752 | f'/auth/login/?next=/answers/{self.answer.pk}/edit/', 753 | ) 754 | 755 | def test_post__authenticated(self) -> None: 756 | self.client.force_login(self.answer_author) 757 | response = self.client.post( 758 | self.path, 759 | data=dict( 760 | body='This is an updated answer', 761 | ), 762 | ) 763 | assert \ 764 | 'This is an updated answer' == \ 765 | models.Answer.objects.filter(pk=self.answer.pk).get().body 766 | self.assertRedirects(response, f'/questions/{self.question.pk}/') 767 | 768 | def test_post__authenticated__not_owner(self) -> None: 769 | """ 770 | Only the question authors are allowed to edit the question 771 | """ 772 | self.client.force_login(self.author) 773 | response = self.client.post(self.path, data=dict( 774 | body='This is an updated answer', 775 | )) 776 | assert HTTPStatus.FORBIDDEN == response.status_code 777 | 778 | 779 | class TestSubjectDetails(TestCase): 780 | 781 | def setUp(self) -> None: 782 | self.user: models.User = models.User.objects.create() 783 | self.maths: models.Subject = models.Subject.objects.create( 784 | title="Mathematics", 785 | description="the study of numbers", 786 | ) 787 | 788 | self.biology: models.Subject = models.Subject.objects.create( 789 | title='Biology', 790 | short_title='bio', 791 | ) 792 | self.question: models.Question = models.Question.objects.create( 793 | author=self.user, 794 | title='Example question?', 795 | body='A question.', 796 | subject=self.maths, 797 | grade=7, 798 | ) 799 | self.path = reverse('subject-detail', kwargs=dict(pk=self.maths.pk)) 800 | 801 | def test_not_found(self) -> None: 802 | response = self.client.get(reverse('subject-detail', kwargs=dict(pk=404))) 803 | assert HTTPStatus.NOT_FOUND == response.status_code 804 | self.assertTemplateUsed(response, '404.html') 805 | 806 | def test_get_authenticated(self) -> None: 807 | response = self.client.get(self.path) 808 | assert HTTPStatus.OK == response.status_code 809 | self.assertTemplateUsed(response, 'buza/subject_detail.html') 810 | 811 | self.assertContains(response, self.maths.title) 812 | self.assertContains(response, "Ask New Question") 813 | self.assertContains(response, self.biology.title, count=1) 814 | self.assertContains(response, self.question.title, count=1) 815 | self.assertNotContains(response, "Follow") 816 | self.assertNotContains(response, "Following") 817 | # Listed subjects by title. 818 | self.assertQuerysetEqual(response.context['subject_list'], [ 819 | '', 820 | '', 821 | ]) 822 | 823 | def test_get__authenticated__subject_short_title(self) -> None: 824 | self.maths: models.Subject = models.Subject.objects.create( 825 | title="mathematics", 826 | short_title="maths", 827 | description="the study of numbers", 828 | ) 829 | self.client.force_login(self.user) 830 | path = reverse('subject-detail', kwargs=dict(pk=self.maths.pk)) 831 | response = self.client.get(path) 832 | 833 | assert HTTPStatus.OK == response.status_code 834 | self.assertContains(response, self.maths.title) 835 | self.assertContains( 836 | response, 837 | "Ask New " + self.maths.short_title + " Question", 838 | ) 839 | 840 | def test_post_unauthenticated(self) -> None: 841 | """Redirect unauthenticated user's posts """ 842 | 843 | response: HttpResponse = self.client.post(self.path, data={ 844 | 'follow-subject': self.maths.pk, 845 | }) 846 | 847 | assert HTTPStatus.FOUND == response.status_code 848 | self.assertRedirects(response, f'/auth/login/?next=/subjects/{self.maths.pk}/') 849 | 850 | def test_post__follow_subject(self) -> None: 851 | """Redirect unauthenticated user's posts """ 852 | self.client.force_login(self.user) 853 | response: HttpResponse = self.client.post(self.path, data={ 854 | 'follow-subject': self.maths.pk, 855 | 856 | }) 857 | 858 | assert HTTPStatus.FOUND == response.status_code 859 | self.assertRedirects(response, f'/subjects/{self.maths.pk}/') 860 | self.assertEqual(self.user.subjects.all().count(), 1) 861 | 862 | response = self.client.get(response.url) 863 | self.assertContains(response, "following") 864 | self.assertContains(response, "follow") 865 | # Maths (followed) listed first. 866 | self.assertContains(response, self.maths.title, count=3) 867 | self.assertQuerysetEqual(response.context['subject_list'], [ 868 | '', 869 | '', 870 | ]) 871 | 872 | def test_get__no_followed_subjects(self) -> None: 873 | """ 874 | Test subject list and question list 875 | """ 876 | self.client.force_login(self.user) 877 | response = self.client.get(self.path) 878 | 879 | assert HTTPStatus.OK == response.status_code 880 | # Question list and button 881 | self.assertContains(response, "Ask New Question") 882 | self.assertContains(response, self.question.title, count=1) 883 | # Subjects listing 884 | self.assertContains(response, "follow") 885 | self.assertContains(response, "★") 886 | self.assertContains(response, "following", 0) 887 | self.assertContains(response, self.maths.title, count=3) 888 | self.assertContains(response, self.biology.title, count=1) 889 | 890 | # Listed subjects by title. 891 | self.assertQuerysetEqual(response.context['subject_list'], [ 892 | '', 893 | '', 894 | ]) 895 | 896 | def test_get__followed_subjects(self) -> None: 897 | """ 898 | When follow a question, the UI updates 899 | :return: 900 | """ 901 | self.client.force_login(self.user) 902 | self.user.subjects.add(self.maths) 903 | response = self.client.get(self.path) 904 | self.assertTemplateUsed(response, 'buza/subject_detail.html') 905 | assert HTTPStatus.OK == response.status_code 906 | self.assertContains(response, "following") 907 | self.assertContains(response, "follow") 908 | 909 | # Maths (followed) listed first. 910 | self.assertQuerysetEqual(response.context['subject_list'], [ 911 | '', 912 | '', 913 | ]) 914 | 915 | def test_post__unfollow_subject(self) -> None: 916 | """Redirect unauthenticated user's posts """ 917 | self.client.force_login(self.user) 918 | 919 | # follow and unfollow a subject 920 | self.client.post(self.path, data={ 921 | 'follow-subject': self.maths.pk, 922 | 923 | }) 924 | response: HttpResponse = self.client.post(self.path, data={ 925 | 'following-subject': self.maths.pk, 926 | 927 | }) 928 | 929 | assert HTTPStatus.FOUND == response.status_code 930 | self.assertRedirects(response, f'/subjects/{self.maths.pk}/') 931 | self.assertEqual(self.user.subjects.all().count(), 0) 932 | 933 | response = self.client.get(response.url) 934 | self.assertNotContains(response, "following") 935 | self.assertContains(response, "follow") 936 | # Maths (followed) listed first. 937 | self.assertQuerysetEqual(response.context['subject_list'], [ 938 | '', 939 | '', 940 | ]) 941 | 942 | 943 | class Test404PageNotFound(TestCase): 944 | 945 | def test_url_not_found(self): 946 | response = self.client.get('404/not-found/test') 947 | self.assertTemplateUsed(response, '404.html') 948 | self.assertContains( 949 | response, 950 | 'We could not find the page you were looking for', 951 | status_code=HTTPStatus.NOT_FOUND, 952 | ) 953 | self.assertContains(response, 'Take me home', status_code=HTTPStatus.NOT_FOUND) 954 | 955 | 956 | class TestPrivacyPolicy(TestCase): 957 | 958 | def test_privacy_policy(self) -> None: 959 | response = self.client.get(reverse("privacy-policy")) 960 | self.assertTemplateUsed(response, "accounts/privacy_policy.html") 961 | self.assertContains( 962 | response, 963 | "Privacy Policy for", 964 | ) 965 | 966 | 967 | class TestTermsOfService(TestCase): 968 | 969 | def test_privacy_policy(self) -> None: 970 | response = self.client.get(reverse("terms-of-service")) 971 | self.assertTemplateUsed(response, "accounts/terms_of_service.html") 972 | self.assertContains( 973 | response, 974 | "Welcome to Buza Answers", 975 | ) 976 | 977 | 978 | class TestHomagePageView(TestCase): 979 | 980 | def test__get__unauthenticated(self) -> None: 981 | """Unauthenticated users are directed to the login page 982 | """ 983 | response = self.client.get(reverse('home')) 984 | expected_url = f'/auth/login/' 985 | self.assertRedirects(response, expected_url) 986 | 987 | def test__get__authenticated(self) -> None: 988 | """Authenticated users are directed to their profile 989 | """ 990 | self.user: models.User = models.User.objects.create() 991 | self.client.force_login(self.user) 992 | response = self.client.get(reverse('home')) 993 | expected_url = f'/users/{self.user.pk}/' 994 | self.assertRedirects(response, expected_url) 995 | --------------------------------------------------------------------------------