diff --git a/.gitignore b/.gitignore index 439a33f..b2bd2d3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ wheels/ *.egg-info/ .installed.cfg *.egg +*.DS_Store +*.idea/ # PyInstaller # Usually these files are written by a python script from a template @@ -84,6 +86,7 @@ celerybeat-schedule # virtualenv .venv +.venv/ venv/ ENV/ @@ -100,7 +103,7 @@ ENV/ # mypy .mypy_cache/ -users/config.py +# virtual env flask #configuration file @@ -110,4 +113,9 @@ backend/config.py /.idea #Migrations files -/backend/migrations \ No newline at end of file +/backend/migrations + +/backend/config.py +/client/basic/local_settings.py +/client/apps/city_issues/media/ +/client/static diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..fbaad23 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,426 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +# Make pylint work with django applications +init-hook='import sys; sys.path.append("client/apps")' + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=invalid-name,cyclic-import, too-few-public-methods,no-init + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[BASIC] + +# Naming hint for argument names +argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct argument names +argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Naming hint for attribute names +attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct attribute names +attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming hint for function names +function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct function names +function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for method names +method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct method names +method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming hint for variable names +variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=db.*, Issues.objects, Category.objects, Role.objects, Attachments.objects + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b142882 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: python +python: + - 2.7 +install: + - pip install -r requirements.txt + - pip install pycodestyle + - pip install pylint==1.7.4 + +jobs: + include: + - stage: PEP8 + script: pycodestyle --ignore=E402,E501 backend client + + - stage: Pylint + script: python pylint_check.py --load pylint_django --targets backend client --score 7 + +notifications: + slack: rv-027py:bKX55idBpm5OTbuPu9gphYF8 + email: false diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..67dd28b --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: eval '$WEB_START_COMMAND' \ No newline at end of file diff --git a/README.md b/README.md index 19d71fa..ee9b1f1 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,48 @@ -# rv-027py - -### Installation - - For installing required libs and frameworks execute next commands: +### Installing required libs and frameworks ``` -pip install -r requirements.txt +pip install -r requirements.txt or requirements/dev.txt ``` ### Configuration - -Copy config.py.example and rename this file to config.py ``` -Set db credentials +Rename file config.py.example into config.py and +fill config.py with your database credentials. ``` - -### Migrations and DB - -1) Initialize migration (choose directory with migrations.py) & enter following command +### Prepare Command Line Interface (use set on Windows) +``` +export FLASK_APP=backend/app.py +export FLASK_DEBUG=1 ``` -migrations.py db init +### Create database and test records (from the root) ``` -2) Create migrations files +flask initdb +flask insertdata +flask download_and_extract_images ``` -migrations.py db migrate +### Provide migrations ``` -3) Create tables from migrations +python client/manage.py migrate ``` -migrations.py db upgrade +### Run Flask application (from the root) ``` -Create test records +flask run ``` -python create_database.py + +### To drop database (from the root) +``` +flask dropdb ``` -### Run application +### Django Settings +``` +Rename file local_settings.py.example into local_settings.py and +fill it up with your database credentials. +``` -For the start application execute +### Run Django application (from the root) ``` -python run.py +python client/manage.py runserver ``` +### Mail settings + +For correct email notification in Flask on your google account enable [settings](https://goo.gl/Lm1dm8) + diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..3869093 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,64 @@ +"""This module create instance of Flask and activate packages.""" +# pylint: disable=wrong-import-position +import os +import sys +import logging + +from flask import Flask +from flask_bootstrap import Bootstrap +from flask_wtf.csrf import CSRFProtect +from flask_sqlalchemy import SQLAlchemy +from flask_mail import Mail + + +app = Flask(__name__) +app.config.from_object('backend.config.DevelopmentConfig') + +if 'DEBUG' in os.environ and os.environ['DEBUG'] == 'False': + app.config.from_object('backend.config.ProductionConfig') + app.logger.addHandler(logging.StreamHandler(sys.stdout)) + app.logger.setLevel(logging.ERROR) + +db = SQLAlchemy(app) +Bootstrap(app) +CSRFProtect(app) +mail = Mail(app) + + +from backend.create_database import db_create +from backend.drop_database import db_drop +from backend.insert_db_data import db_insert_data +from backend.create_tables import db_create_tables +from backend.dowload_attachments import download_and_extract_attachments +# pylint: disable=unused-import +from backend.views import views + + +@app.cli.command() +def initdb(): + """Creating database""" + db_create() + + +@app.cli.command() +def dropdb(): + """Dropping database""" + db_drop() + + +@app.cli.command() +def insertdata(): + """Inserting data into database""" + db_insert_data() + + +@app.cli.command() +def download_and_extract_images(): + """Download and extract images from Google Drive""" + download_and_extract_attachments() + + +@app.cli.command() +def createtables(): + """Inserting data into database""" + db_create_tables() diff --git a/backend/config.py.example b/backend/config.py.example index f434c5e..4a6a477 100644 --- a/backend/config.py.example +++ b/backend/config.py.example @@ -1,25 +1,55 @@ -# Rename this file to config.py and fill db_credentials with your's data. +# Rename this file to config.py and: +# Set SQLALCHEMY_DATABASE_URI +# Set SECRET_KEY +# Set WTF_CSRF_SECRET_KEY import os class Config(object): - # Change database credentials to yours. - db_credentials = 'postgres://user:password@localhost/db_name' + """ Global configuration.""" DEBUG = False - SECRET_KEY = 'SECRET_OR_NOT_KEY' + SECRET_KEY = '' + WTF_CSRF_ENABLED = True - WTF_CSRF_SECRET_KEY = 'SECRET_OR_NOT_KEY' + WTF_CSRF_SECRET_KEY = '' + + SQLALCHEMY_DATABASE_URI = 'postgres://user:password@localhost/db_name' - SQLALCHEMY_DATABASE_URI = db_credentials SQLALCHEMY_TRACK_MODIFICATIONS = False + # password hashing rounds + BCRYPT_LOG_ROUNDS = 14 + + MEDIA_FOLDER = os.path.abspath(os.path.join( + 'client', 'apps', 'city_issues', 'media')) + + # mail configure + MAIL_SERVER = 'smtp.googlemail.com' + MAIL_PORT = 587 + MAIL_USE_TLS = True + MAIL_USERNAME = 'info.cityissues@gmail.com' + MAIL_PASSWORD = 'passqwerty' + ADMIN_MAIL_SUBJECT_PREFIX = '[CityView]' + ADMIN_MAIL_SENDER = 'CityView Admin ' + class ProductionConfig(Config): + """ Production configuration.""" DEBUG = False + if 'DATABASE_URL' in os.environ: + SQLALCHEMY_DATABASE_URI = os.environ['DATABASE_URL'] + if 'SECRET_KEY' in os.environ: + SECRET_KEY = os.environ['SECRET_KEY'] + if 'WTF_CSRF_SECRET_KEY' in os.environ: + WTF_CSRF_SECRET_KEY = os.environ['WTF_CSRF_SECRET_KEY'] + + MEDIA_URL = 'https://limitless-oasis-36193.herokuapp.com/media/app/client/media/' + class DevelopmentConfig(Config): - DEBUG = True \ No newline at end of file + """ Development configuration.""" + DEBUG = True diff --git a/backend/create_database.py b/backend/create_database.py index 95a477f..1072178 100644 --- a/backend/create_database.py +++ b/backend/create_database.py @@ -1,68 +1,32 @@ +"""This module create database, if it not exists""" +# pylint: disable=no-name-in-module,import-error import os -from sqlalchemy_utils.functions.database import create_database, database_exists -from models.users import Role, User -from models.issues import Attachment, Category, Issue, IssueHistory, Status -from manage import db -from config import Config -#Checking if database exists, and if not -> create it with all tables. +from sqlalchemy_utils.functions.database import (create_database, + database_exists) -db_credentials = Config.db_credentials +from backend.app import db +from backend.config import Config -if 'DATABASE_URL' in os.environ: - db_credentials = os.environ['DATABASE_URL'] - - -if not database_exists(db_credentials): - create_database(db_credentials) - db.create_all() - db.session.commit() - - -#Creating some test data. -role = Role(role='admin') -role1 = Role(role='moderator') -role2 = Role(role='user') -category = Category(category='road accident', favicon='') -category1 = Category(category='infrastructure accident', favicon='') -category2 = Category(category='another accident', favicon='') +# Checking if database exists, and if not -> create it with all tables. -status1 = Status(status="new") -status2 = Status(status="working") -status3 = Status(status="closed") +db_credentials = Config.SQLALCHEMY_DATABASE_URI -user1 = User(name='Bob', alias='Bobby', email='bob@gmail.com', password='crypto', role_id = '1', avatar=None, delete_date=None) -user2 = User(name='Mark', alias='Marky', email='mark@gmail.com', password='123', role_id = '2', avatar=None, delete_date=None) -user3 = User(name='Maria', alias='Mary', email='maria@gmail.com', password='321', role_id = '3', avatar=None, delete_date=None) - -issue1 = Issue(name='Road accident', user_id='2', category_id='1', location='', description='Car crash ...', - open_date='2017/10/25', close_date=None, delete_date=None) -issue2 = Issue(name='Road accident', user_id='3', category_id='1', location='', description='Bus crash ...', - open_date='2016/01/12', close_date='2016/01/20', delete_date=None) -issue3 = Issue(name='Dog lost', user_id='3', category_id='3', location='', description='Poor puppy is lost', - open_date='2017/09/20', close_date='2017/09/25', delete_date='2017/09/26') - -attachment1 = Attachment(issue_id='1', image_url='some url1', delete_date=None) -attachment2 = Attachment(issue_id='2', image_url='some url2', delete_date='2016/01/20') -attachment3 = Attachment(issue_id='3', image_url='some url3', delete_date='2017/09/26') - -issueHistory1 = IssueHistory(user_id='1', issue_id='1', status_id='1', transaction_date='2017/10/25', delete_date=None) -issueHistory2 = IssueHistory(user_id='1', issue_id='1', status_id='2', transaction_date='2017/10/27', delete_date=None) -issueHistory3 = IssueHistory(user_id='3', issue_id='3', status_id='1', transaction_date='2017/09/20', delete_date=None) -issueHistory4 = IssueHistory(user_id='3', issue_id='3', status_id='2', transaction_date='2017/09/25', delete_date=None) -issueHistory5 = IssueHistory(user_id='3', issue_id='3', status_id='3', transaction_date='2017/09/26', delete_date=None) +if 'DATABASE_URL' in os.environ: + db_credentials = os.environ['DATABASE_URL'] -#Insert test data into database. -db.session.add_all([role, role1, role2, - category, category1, category2, - status1, status2, status3, - user1, user2, user3, - issue1, issue2, issue3, - issueHistory1, issueHistory2, issueHistory3, issueHistory4, issueHistory5 - ]) -db.session.commit() +def db_create(): + """Creating database, if it not exists""" + if not database_exists(db_credentials): + create_database(db_credentials) + db.create_all() + db.session.commit() + print "Successfully created database and tables." + else: + print "The database already exists!" -print "Ok" \ No newline at end of file +if __name__ == '__main__': + db_create() diff --git a/backend/create_tables.py b/backend/create_tables.py new file mode 100644 index 0000000..4443621 --- /dev/null +++ b/backend/create_tables.py @@ -0,0 +1,9 @@ +"""This module create all tables.""" +# pylint: disable=no-name-in-module,import-error +from backend.app import db + + +def db_create_tables(): + """Creating all tables.""" + db.create_all() + print "Successfully created tables." diff --git a/backend/dowload_attachments.py b/backend/dowload_attachments.py new file mode 100644 index 0000000..b70d202 --- /dev/null +++ b/backend/dowload_attachments.py @@ -0,0 +1,34 @@ +import os +import urllib2 +import zipfile +# pylint: disable=no-name-in-module,import-error +from backend.config import Config + + +def download_attachments(url, zip_file_name): + zip_file = urllib2.urlopen(url) + with open(zip_file_name, 'wb') as output: + output.write(zip_file.read()) + print 'File successfully downloaded' + + +def unzip_file(zip_file_path, directory_to_extract_to): + zip_ref = zipfile.ZipFile(zip_file_path, 'r') + zip_ref.extractall(directory_to_extract_to) + zip_ref.close() + os.remove(zip_file_path) + print 'Files successfully extracted' + + +def download_and_extract_attachments(): + url = 'https://drive.google.com/uc?export=download&id=1fJTbJJ_NZcO-gBZOqW7-uGeTLaYsH5Kh' + zip_file_name = 'attachments.zip' + zip_file_path = os.path.realpath(zip_file_name) + directory_to_extract = os.path.join(Config.MEDIA_FOLDER, 'uploads') + + download_attachments(url, zip_file_name) + unzip_file(zip_file_path, directory_to_extract) + + +if __name__ == '__main__': + download_and_extract_attachments() diff --git a/backend/drop_database.py b/backend/drop_database.py index 295dfd0..ed46a60 100644 --- a/backend/drop_database.py +++ b/backend/drop_database.py @@ -1,7 +1,23 @@ +"""This module drops database""" +# pylint: disable=no-name-in-module,import-error import os + from sqlalchemy_utils.functions.database import drop_database -#Dropping our test base. -drop_database(os.environ['DATABASE_URL']) +from backend.config import Config + + +db_credentials = Config.SQLALCHEMY_DATABASE_URI + +if 'DATABASE_URL' in os.environ: + db_credentials = os.environ['DATABASE_URL'] + + +def db_drop(): + """This function drops database""" + drop_database(db_credentials) + print 'DB dropped' + -print 'DB dropped' +if __name__ == '__main__': + db_drop() diff --git a/backend/forms/forms.py b/backend/forms/forms.py index ee83514..acf4b4b 100644 --- a/backend/forms/forms.py +++ b/backend/forms/forms.py @@ -1,18 +1,287 @@ +"""This module contains forms classes for admin manage.""" from flask_wtf import FlaskForm -from wtforms import StringField, IntegerField, DateField, HiddenField, PasswordField -from wtforms.validators import DataRequired, Email, Optional - -class UserForm(FlaskForm): - """User info modifying form""" - id = HiddenField('id') - name = StringField('name', validators=[DataRequired()]) - alias = StringField('alias', validators=[DataRequired()]) - email = StringField('email', validators=[Email()]) - role_id = IntegerField('role_id', validators=[DataRequired()]) - delete_date = DateField('delete_date',validators=[Optional()]) - - -class LoginForm(FlaskForm): - """Login form""" - email = StringField('login', validators=[Email()]) - password = PasswordField('password', validators=[DataRequired()]) \ No newline at end of file +from wtforms import (StringField, HiddenField, TextAreaField, + PasswordField, SelectField, SubmitField, + FloatField) +from wtforms.validators import (DataRequired, Email, + Length, Regexp, ValidationError) + +from backend.app import db +from backend.models.users import User +from backend.models.issues import Issue + + +class UniqueValue(object): + """Custom validator. + + Validate for unique field value. + Skips record in database with current user's id. + + """ + + # pylint: disable=too-few-public-methods + + def __init__(self, model, property_to_find, message=None): + + if not message: + message = "This field's value is already exists in database." + self.message = message + self.model = model + self.property_to_find = property_to_find + + def __call__(self, form, field): + + record_id = None + if form.id.data: + record_id = form.id.data + + query = db.session.query(self.model).filter( + self.model.id != record_id).filter( + self.property_to_find == field.data).first() + + if query: + raise ValidationError(self.message) + + +check_email = UniqueValue( + User, User.email, + message="This email is already exists in database.") + +check_alias = UniqueValue( + User, User.alias, + message="This alias is already exists in database.") + + +class BaseForm(FlaskForm): + """Adds csrf""" + class Meta: + csrf = True + + +class UserForm(BaseForm): + """User info modifying form.""" + + id = HiddenField('id') + name = StringField( + 'name', + description=u'Length between 3 and 15 chars.', + validators=[ + DataRequired(), + Length(min=3, max=15), + Regexp( + r"^[\w]+$", + message='Only letters, numbers and "_" may be used.') + ] + ) + alias = StringField( + 'alias', + description=u'Length between 3 and 15 chars.', + validators=[ + DataRequired(), + check_alias, + Length(min=3, max=15), + Regexp( + r"^[\w]+$", + message='Only letters, numbers and "_" may be used.') + ] + ) + email = StringField('email', validators=[Email(), check_email]) + + role_id = SelectField( + 'role_id', + choices=[ + ('1', 'admin'), + ('2', 'moderator'), + ('3', 'user') + ], + validators=[DataRequired()] + ) + + submit_button = SubmitField('Save') + + +class UserAddForm(BaseForm): + """User add form.""" + + id = HiddenField('id') + name = StringField( + 'name', + description=u'Length between 3 and 15 chars.', + validators=[ + DataRequired(), + Length(min=3, max=15), + Regexp( + r"^[\w]+$", + message='Only letters, numbers and "_" may be used.') + ] + ) + alias = StringField( + 'alias', + description=u'Length between 3 and 15 chars.', + validators=[ + DataRequired(), + check_alias, + Length(min=3, max=15), + Regexp( + r"^[\w]+$", + message='Only letters, numbers and "_" may be used.') + ] + ) + email = StringField('email', validators=[Email(), check_email]) + + password = PasswordField( + 'password', + description=u'Length between 3 and 20 chars.', + validators=[ + DataRequired(), + Length(min=3, max=20), + Regexp( + r"^[\w]+$", + message='Only letters, numbers and "_" may be used.') + ] + ) + + role_id = SelectField( + 'role_id', + choices=[ + ('1', 'admin'), + ('2', 'moderator'), + ('3', 'user') + ], + validators=[DataRequired()] + ) + + submit_button = SubmitField('Save') + + +class IssueForm(BaseForm): + """Issue edit form""" + + id = HiddenField('id') + title = StringField( + 'title', + description=u'Length between 3 and 15 chars.', + validators=[ + DataRequired(), + Length(min=3, max=15) + ] + ) + + status = SelectField( + 'status', + choices=[ + ('new', 'new'), + ('on moderation', 'on moderation'), + ('open', 'open'), + ('closed', 'closed'), + ('deleted', 'deleted'), + ('pending close', 'pending close'), + + ], + validators=[DataRequired()] + ) + + description = TextAreaField( + 'description', + description=u'Length between 10 and 144 chars.', + validators=[ + DataRequired(), + Length(min=10, max=144) + ] + ) + + location_lat = FloatField( + 'location lat', + render_kw={'readonly': True}, + validators=[ + DataRequired(), + ] + ) + + location_lon = FloatField( + 'location lot', + render_kw={'readonly': True}, + validators=[ + DataRequired(), + ] + ) + + category_id = SelectField( + 'category', + choices=[ + ('1', 'road accident'), + ('2', 'infrastructure accident'), + ('3', 'another accident'), + ('4', 'accident with animals') + ], + validators=[DataRequired()] + ) + + submit_button = SubmitField('Save') + + +class LoginForm(BaseForm): + """Login form.""" + + email = StringField('login', validators=[Email()]) + password = PasswordField('password', validators=[DataRequired()]) + submit_button = SubmitField('Login') + + +class SearchUserForm(BaseForm): + """Search form""" + + search = StringField( + 'search' + ) + search_by = SelectField( + 'search_by', + choices=[ + ('0', 'name'), + ('1', 'alias'), + ('2', 'email'), + ('3', 'name+alias'), + ('4', 'alias+email'), + ('5', 'email+name'), + ('6', 'email+name+alias') + ] + ) + order_by = SelectField( + 'order_by', + choices=[ + ('0', 'id'), + ('1', 'role'), + ('2', 'delete date') + ] + ) + + class Meta: + csrf = False + + +class SearchIssuesForm(BaseForm): + """Search form""" + + search = StringField( + 'search' + ) + + search_by = SelectField( + 'search_by', + choices=[ + ('0', 'summary'), + ('1', 'category'), + ('2', 'description'), + ] + ) + + order_by = SelectField( + 'order_by', + choices=[ + ('0', 'summary'), + ('1', 'category'), + ] + ) + + class Meta: + csrf = False diff --git a/backend/insert_db_data.py b/backend/insert_db_data.py new file mode 100644 index 0000000..37a9b00 --- /dev/null +++ b/backend/insert_db_data.py @@ -0,0 +1,194 @@ +"""This module insert database data""" +# pylint: disable=no-name-in-module,import-error +import os + +from backend.app import db +from backend.config import Config +from backend.models.users import Role, User +from backend.models.issues import (Attachment, Category, Comments, + Issue, IssueHistory, Status) + +db_credentials = Config.SQLALCHEMY_DATABASE_URI + +if 'DATABASE_URL' in os.environ: + db_credentials = os.environ['DATABASE_URL'] + +role = Role(role='admin') +role1 = Role(role='moderator') +role2 = Role(role='user') + +category = Category(category='Road accident', favicon='') +category1 = Category(category='Infrastructure accident', favicon='') +category2 = Category(category='Another accident', favicon='') +category3 = Category(category='Accident with animals', favicon='') + +status1 = Status(status="new") +status2 = Status(status="on moderation") +status3 = Status(status="open") +status4 = Status(status="closed") +status5 = Status(status="deleted") +status6 = Status(status="pending close") + +user1 = User(name='Bob', alias='Bobby', email='bob@gmail.com', + password='crypto', role_id='1') +user2 = User(name='Mark', alias='Marky', email='mark@gmail.com', + password='123', role_id='2') +user3 = User(name='Maria', alias='Mary', email='maria@gmail.com', + password='321', role_id='3') +user4 = User(name='Petya', alias='Petya', email='petya@gmail.com', + password='321', role_id='3') +user5 = User(name='Tom', alias='Tom', email='tom@gmail.com', + password='321', role_id='3') +user6 = User(name='Jerry', alias='Jerry', email='jerry@gmail.com', + password='321', role_id='2') +user7 = User(name='Olivia', alias='Olivia', email='olivia@gmail.com', + password='321', role_id='3') +user8 = User(name='Jack Sparrow', alias='Jack', email='jack@gmail.com', + password='321', role_id='1') +user9 = User(name='Leo', alias='Leo', email='leo1991@gmail.com', + password='321', role_id='3') +user10 = User(name='Amelia', alias='Ameli', email='amelia@gmail.com', + password='321', role_id='2') +user11 = User(name='Harry', alias='Harry', email='harry@gmail.com', + password='321', role_id='3') +user12 = User(name='Maximus', alias='Max', email='max@gmail.com', + password='321', role_id='3') + +issue1 = Issue(title='Car crash', user_id='2', category_id='1', location_lat='50.620226734521204', + location_lon='26.239514350891117', description='Two cars find one way in same moment of time ....', status='on moderation', + open_date='2017/11/15') +issue2 = Issue(title='Trolleybus is broken', user_id='3', category_id='1', location_lat='50.6190831458868', + location_lon='26.252110004425052', description='It is very heavy traffic in this place', status='new', + open_date='2017/11/16') +issue3 = Issue(title='Prohibited parking', user_id='3', category_id='1', location_lat='50.61954603035055', + location_lon='26.25116586685181', description='As you see prohibited parking detecting', status='new', + open_date='2017/11/17') +issue4 = Issue(title='Stolen car wheels', user_id='1', category_id='1', location_lat='50.61487613411816', + location_lon='26.25116586685181', description='My car wheels have been stolen by some bastards ', status='open', + open_date='2017/11/18') +issue5 = Issue(title='Heavy traffic', user_id='2', category_id='1', location_lat='50.61320139365915', + location_lon='26.239514350891117', description='All cars are here, because Soborna street is closed', status='closed', + open_date='2017/11/19', close_date='2017/11/25') +issue6 = Issue(title='No electricity', user_id='3', category_id='2', location_lat='50.6250186130551', + location_lon='26.253225803375248', description='There is no electricity in that place after storm', status='open', + open_date='2017/11/20') +issue7 = Issue(title='No water', user_id='1', category_id='2', location_lat='50.62209181346729', + location_lon='26.283631324768066', description='We dont have water for two days. Rivnevodocanal cant say nothing about it', status='open', + open_date='2017/11/21') +issue8 = Issue(title='Gas smell', user_id='2', category_id='2', location_lat='50.62229601469869', + location_lon='26.231789588928226', description='we have strong gas smell here, call 104', status='closed', + open_date='2016/11/22', close_date='2017/01/15') +issue9 = Issue(title='Fire alarm', user_id='3', category_id='2', location_lat='50.62601232218674', + location_lon='26.25416994094849', description='Somebody fire that building, you can see some foto ...', status='open', + open_date='2017/11/23') +issue10 = Issue(title='Broken tree', user_id='1', category_id='3', location_lat='50.615978979420014', + location_lon='26.26311779022217', description='Old tree finally falls, you cant run and walk there', status='open', + open_date='2017/11/24') +issue11 = Issue(title='Street musician', user_id='2', category_id='3', location_lat='50.62004975238461', + location_lon='26.24080181121826', description='Very beautiful music', status='on moderation', + open_date='2017/11/26') +issue12 = Issue(title='Bad company', user_id='3', category_id='3', location_lat='50.607700200565034', + location_lon='26.231789588928226', description='I can see very suspicious there every day, beware', status='closed', + open_date='2014/11/27', close_date='2014/12/01') +issue13 = Issue(title='Beer fest', user_id='1', category_id='3', location_lat='50.617095413757845', + location_lon='26.255307197570804', description='Beer fest is running here, come on', status='on moderation', + open_date='2017/11/28') +issue14 = Issue(title='Prankers', user_id='2', category_id='3', location_lat='50.63607074324129', + location_lon='26.268010139465332', description='Some prankers offer to buy brick, but I have two already', status='open', + open_date='2017/11/29') +issue15 = Issue(title='Dog lost', user_id='3', category_id='4', location_lat='50.63979957034144', + location_lon='26.265778541564945', description='My lovely Rex disappeared in the dark, please help me find it.', status='closed', + open_date='2017/11/30', close_date='2017/12/10') +issue16 = Issue(title='Cat lost', user_id='1', category_id='4', location_lat='50.61943711676894', + location_lon='26.283631324768066', description='Big dark cat, cats nickname is Rambo, please return it for reward', status='open', + open_date='2017/12/01') +issue17 = Issue(title='Dog found', user_id='2', category_id='4', location_lat='50.63895584704026', + location_lon='26.206941604614258', description='Hungry and sick, anybody know it ?', status='open', + open_date='2017/12/02') +issue18 = Issue(title='Poor puppies', user_id='3', category_id='4', location_lat='50.63127999106349', + location_lon='26.20951652526856', description='We have four puppies to your lonely heart', status='new', + open_date='2017/12/03') +issue19 = Issue(title='Ugly hounds', user_id='1', category_id='4', location_lat='50.634083729153225', + location_lon='26.263289451599125', description='Beware, its dangerous to go there', status='deleted', + open_date='2017/12/04') + + +attachment1 = Attachment(issue_id='1', image_url='uploads/Car crash/car-crash.jpg') +attachment2 = Attachment(issue_id='2', image_url='uploads/Trolleybus is broken/trolleybus-broken.jpg') +attachment3 = Attachment(issue_id='3', image_url='uploads/Prohibited parking/prohibited-parking.jpg') +attachment4 = Attachment(issue_id='4', image_url='uploads/Stolen car wheels/stolen-wheels.jpg') +attachment5 = Attachment(issue_id='5', image_url='uploads/Heavy traffic/heavy-traffic.jpg') +attachment6 = Attachment(issue_id='6', image_url='uploads/No electricity/no-electricity.jpg') +attachment7 = Attachment(issue_id='7', image_url='uploads/No water/no-water.jpg') +attachment8 = Attachment(issue_id='8', image_url='uploads/Gas smell/gas-smell.jpg') +attachment9 = Attachment(issue_id='9', image_url='uploads/Fire alarm/fire-alarm.jpg') +attachment10 = Attachment(issue_id='10', image_url='uploads/Broken tree/broken-tree.jpg') +attachment11 = Attachment(issue_id='11', image_url='uploads/Street musician/street-musician.jpg') +attachment12 = Attachment(issue_id='12', image_url='uploads/Bad company/bad-company.jpg') +attachment13 = Attachment(issue_id='13', image_url='uploads/Beer fest/beer-fest.jpg') +attachment14 = Attachment(issue_id='14', image_url='uploads/Prankers/prankers.jpg') +attachment15 = Attachment(issue_id='15', image_url='uploads/Dog lost/dog-lost.jpg') +attachment16 = Attachment(issue_id='16', image_url='uploads/Cat lost/lost-cat.jpg') +attachment17 = Attachment(issue_id='17', image_url='uploads/Dog found/dog-found.jpg') +attachment18 = Attachment(issue_id='18', image_url='uploads/Poor puppies/poor-puppies.jpg') +attachment19 = Attachment(issue_id='19', image_url='uploads/Ugly hounds/ugly-hounds.jpg') + + +issueHistory1 = IssueHistory(user_id='1', issue_id='1', status_id='1', + transaction_date='2017/09/25') +issueHistory2 = IssueHistory(user_id='1', issue_id='1', status_id='2', + transaction_date='2017/10/27') +issueHistory3 = IssueHistory(user_id='3', issue_id='3', status_id='1', + transaction_date='2017/09/20') +issueHistory4 = IssueHistory(user_id='3', issue_id='3', status_id='2', + transaction_date='2017/09/25') +issueHistory5 = IssueHistory(user_id='3', issue_id='3', status_id='3', + transaction_date='2017/09/26') +issueHistory6 = IssueHistory(user_id='2', issue_id='2', status_id='1', + transaction_date='2017/11/11') +issueHistory7 = IssueHistory(user_id='3', issue_id='3', status_id='4', + transaction_date='2017/11/17') + + +comment1 = Comments(user_id='1', issue_id='1', + date_public='2017/09/26', comment='It is good that no one was hurt', status='public') +comment2 = Comments(user_id='2', issue_id='1', + date_public='2017/10/10', comment='Smashed car headlights', status='public') +comment3 = Comments(user_id='3', issue_id='1', + date_public='2017/11/06', comment='Good', status='private') +comment4 = Comments(user_id='2', issue_id='3', date_public='2017/10/16', + comment='Photo is low quality, please upload other', status='internal') +comment5 = Comments(user_id='3', issue_id='3', date_public='2017/10/16', + comment='Ok, I take a picture and upload it', status='internal') + + +def db_insert_data(): + """This function insert database data""" + db.session.add_all([role, role1, role2, + category, category1, category2, category3, + status1, status2, status3, status4, status5, status6, + user1, user2, user3, user4, user5, user6, user7, user8, + user9, user10, user11, user12, + issue1, issue2, issue3, + issue4, issue5, issue6, + issue7, issue8, issue9, + issue10, issue11, issue12, + issue13, issue14, issue15, + issue16, issue17, issue18, + issue19, + issueHistory1, issueHistory2, issueHistory3, + issueHistory4, issueHistory5, issueHistory6, + issueHistory7, + attachment1, attachment2, attachment3, attachment4, + attachment5, attachment6, attachment7, attachment8, + attachment9, attachment10, attachment11, attachment12, + attachment13, attachment14, attachment15, attachment16, + attachment17, attachment18, attachment19, + comment1, comment2, comment3, comment4, comment5]) + db.session.commit() + + print "Test data has been inserted into the database" + + +if __name__ == '__main__': + db_insert_data() diff --git a/backend/manage.py b/backend/manage.py deleted file mode 100644 index 3678f89..0000000 --- a/backend/manage.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -from flask import Flask, render_template -from flask_migrate import Migrate, MigrateCommand -from flask_script import Manager -from flask_sqlalchemy import SQLAlchemy - - -config_object = 'config.DevelopmentConfig' - -if 'APP_SETTINGS' in os.environ: - config_object = os.environ['APP_SETTINGS'] - -app = Flask(__name__) -app.config.from_object(config_object) -db = SQLAlchemy(app) - -migrate = Migrate(app, db) -manager = Manager(app) -manager.add_command('db', MigrateCommand) - - - - - - diff --git a/backend/migrations.py b/backend/migrations.py deleted file mode 100644 index ba3f20e..0000000 --- a/backend/migrations.py +++ /dev/null @@ -1,6 +0,0 @@ -from manage import * -from models import users, issues - - -if __name__ == '__main__': - manager.run() diff --git a/backend/models/issues.py b/backend/models/issues.py index 50c9a28..06fda85 100644 --- a/backend/models/issues.py +++ b/backend/models/issues.py @@ -1,21 +1,57 @@ -from manage import db +"""This module creates Issues model.""" +# pylint: disable=too-few-public-methods + +import os + +from flask import current_app +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.sql.functions import func + +from backend.app import db class Attachment(db.Model): - """This class is used for attachment table in database.""" + """Attachment table in the database.""" __tablename__ = 'attachments' id = db.Column(db.Integer, primary_key=True) issue_id = db.Column(db.ForeignKey(u'issues.id'), index=True) image_url = db.Column(db.Text) - delete_date = db.Column(db.Date) issue = db.relationship(u'Issue') + def get_thumbnail_url(self): + head, tail = os.path.split(self.image_url) + thumb_name = "thumb-{}".format(tail) + return "{}/{}".format(head, thumb_name) + + def delete(self): + db.session.delete(self) + db.session.commit() + delete_file(self.image_url) + delete_file(self.get_thumbnail_url()) + directory_path = os.path.abspath(os.path.join( + current_app.config['MEDIA_FOLDER'], self.image_url, os.pardir)) + if os.path.exists(directory_path) and not os.listdir(directory_path): + os.rmdir(directory_path) + + def get_full_thumbnail_url(self): + url = self.get_thumbnail_url() + if current_app.config.get('MEDIA_URL'): + return current_app.config['MEDIA_URL'] + url + return '/media/' + url + + +def delete_file(url): + file_path = os.path.abspath(os.path.join( + current_app.config['MEDIA_FOLDER'], url)) + if os.path.exists(file_path): + os.remove(file_path) + class Category(db.Model): - """This class is used for category table in database.""" + """Category table in the database.""" __tablename__ = 'category' @@ -25,7 +61,7 @@ class Category(db.Model): class IssueHistory(db.Model): - """This class is used for issueHistory table in database.""" + """IssueHistory table in the database.""" __tablename__ = 'issue_History' @@ -33,8 +69,7 @@ class IssueHistory(db.Model): user_id = db.Column(db.ForeignKey(u'users.id')) issue_id = db.Column(db.ForeignKey(u'issues.id'), index=True) status_id = db.Column(db.ForeignKey(u'statuses.id'), index=True) - transaction_date = db.Column(db.Date) - delete_date = db.Column(db.Date) + transaction_date = db.Column(db.TIMESTAMP(timezone=True)) issue = db.relationship(u'Issue') status = db.relationship(u'Status') @@ -42,29 +77,87 @@ class IssueHistory(db.Model): class Issue(db.Model): - """This class is used for issues table in database.""" + """Issues table in the database.""" __tablename__ = 'issues' id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.Text) + title = db.Column(db.Text) user_id = db.Column(db.ForeignKey(u'users.id'), index=True) category_id = db.Column(db.ForeignKey( u'category.id'), nullable=False, index=True) - location = db.Column(db.Text) + location_lat = db.Column(db.Float) + location_lon = db.Column(db.Float) + status = db.Column(db.Text) description = db.Column(db.Text) - open_date = db.Column(db.Date) - close_date = db.Column(db.Date) - delete_date = db.Column(db.Date) + open_date = db.Column(db.TIMESTAMP(timezone=True)) + close_date = db.Column(db.TIMESTAMP(timezone=True)) + delete_date = db.Column(db.TIMESTAMP(timezone=True)) category = db.relationship(u'Category') user = db.relationship(u'User') + def delete(self): + """Setting deleting date for issue""" + if not self.delete_date: + self.delete_date = func.current_timestamp() + return True + return False + + def restore(self): + """Restoring issue from deletion""" + if self.delete_date: + self.delete_date = None + return True + return False + class Status(db.Model): - """This class is used for status table in database.""" + """Status table in the database.""" __tablename__ = 'statuses' id = db.Column(db.Integer, primary_key=True) status = db.Column(db.Text) + + +class Comments(db.Model): + """ + Issues table in the database. + """ + + __tablename__ = 'comments' + id = db.Column(db.Integer, primary_key=True) + comment = db.Column(db.Text) + date_public = db.Column(db.TIMESTAMP(timezone=True)) + user_id = db.Column(db.ForeignKey(u'users.id')) + issue_id = db.Column(db.ForeignKey(u'issues.id'), index=True) + status = db.Column(db.Text) + pre_deletion_status = db.Column(db.Text) + + issue = db.relationship(u'Issue') + user = db.relationship(u'User') + + class Meta: + """...""" + app_label = 'city_issues' + managed = False + db_table = 'comments' + + +def get_all_issue_history(issue_id): + """Method return all issue history and comments sorted by date.""" + all_history = db.session.query( + IssueHistory).filter(IssueHistory.issue_id == issue_id).order_by( + IssueHistory.transaction_date).all() + comments = db.session.query(Comments).filter( + Comments.issue_id == issue_id).order_by(Comments.date_public).all() + list_history = [] + for history in all_history: + list_history.append(['change_status', history.status.status, history.user.alias, + history.transaction_date.strftime('%Y-%m-%d %H:%M')]) + for comment in comments: + list_history.append(['add_comment', comment.user.alias, + comment.comment, comment.date_public.strftime('%Y-%m-%d %H:%M')]) + list_history.sort(key=lambda history: history[3]) + return list_history diff --git a/backend/models/users.py b/backend/models/users.py index 1da2818..f9613dd 100644 --- a/backend/models/users.py +++ b/backend/models/users.py @@ -1,7 +1,16 @@ -from manage import db +"""This module creates Users model.""" +# pylint: disable=too-few-public-methods + +from sqlalchemy import or_ +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.sql.functions import func +from passlib.hash import django_bcrypt + +from backend.app import db + class Role(db.Model): - """This class is used for role table in database.""" + """Role table in the database""" __tablename__ = 'roles' @@ -10,7 +19,11 @@ class Role(db.Model): class User(db.Model): - """This class is used for user table in database.""" + """User table in the database""" + + ROLE_ADMIN = 1 + ROLE_MODERATOR = 2 + ROLE_USER = 3 __tablename__ = 'users' @@ -18,9 +31,76 @@ class User(db.Model): name = db.Column(db.Text) alias = db.Column(db.Text) email = db.Column(db.Text) - password = db.Column(db.Text) + hashed_password = db.Column(db.Text) role_id = db.Column(db.ForeignKey(u'roles.id'), index=True) avatar = db.Column(db.Text) - delete_date = db.Column(db.Date) + delete_date = db.Column(db.TIMESTAMP) + last_login = db.Column(db.TIMESTAMP) + role = db.relationship(u'Role') + + @hybrid_property + def password(self): + """Getting the password.""" + return self.hashed_password + + @password.setter + def password(self, raw_password): + """Hashing password before being stored.""" + self.hashed_password = django_bcrypt.hash(raw_password) + + def check_password(self, raw_password): + """Checking the password form database.""" + return django_bcrypt.verify(raw_password, self.hashed_password) + + # pylint: disable=no-self-use + # This needs to be checked because no self is used in function + def is_last_admin(self): + """Looking for the last admin""" + count = User.query.filter_by( + role_id=User.ROLE_ADMIN, delete_date=None).count() + if count > 1: + return False + return True + + def delete(self): + """Setting deleting date for user""" + if self.role_id == User.ROLE_ADMIN: + if not self.is_last_admin(): + self.delete_date = func.current_timestamp() + return True + else: + self.delete_date = func.current_timestamp() + return True + return False + + def restore(self): + """Restoring user from deletion""" + if self.delete_date: + self.delete_date = None + return True + return False + + +def user_search(search_string, search_by): + """Method user search.""" + MIN_SEARCH_STR = 2 - role = db.relationship(u'Role') \ No newline at end of file + condition_list = [] + for one_string in search_string.split(): + if len(one_string) < MIN_SEARCH_STR: + continue + search_parameter = '%{}%'.format(one_string) + name_search = User.name.ilike(search_parameter) + alias_search = User.alias.ilike(search_parameter) + email_search = User.email.ilike(search_parameter) + conditions = [ + name_search, + alias_search, + email_search, + or_(name_search, alias_search), + or_(alias_search, email_search), + or_(email_search, name_search), + or_(name_search, alias_search, email_search) + ] + condition_list.append(conditions[search_by]) + return or_(*condition_list) diff --git a/backend/run.py b/backend/run.py deleted file mode 100644 index 7ec9792..0000000 --- a/backend/run.py +++ /dev/null @@ -1,5 +0,0 @@ -from manage import app -import views.views - -if __name__ == '__main__': - app.run() diff --git a/backend/static/assets/css/message.css b/backend/static/assets/css/message.css new file mode 100644 index 0000000..c0baa82 --- /dev/null +++ b/backend/static/assets/css/message.css @@ -0,0 +1,6 @@ +.messages{ + position: absolute; + top: 20px; + right: 0px; + left: 0px; +} \ No newline at end of file diff --git a/backend/static/assets/js/app.js b/backend/static/assets/js/app.js new file mode 100644 index 0000000..9c334f2 --- /dev/null +++ b/backend/static/assets/js/app.js @@ -0,0 +1,30 @@ +$( document ).ready(function() { + $('#deletion').on('show.bs.modal', function(e) { + var elemId = $(e.relatedTarget).data('elem-id'); + var name = $(e.relatedTarget).data('name'); + var funcName = $(e.relatedTarget).data('func-name'); + var elemName = $(e.relatedTarget).data('elem-name'); + if (funcName == 'delete'){ + buttonText ='Delete'; + bodyText = "Please click "+buttonText+" button if you want to delete data."; + } + else { + buttonText ='Restore'; + bodyText = "Please click "+buttonText+" button if you want to restore data."; + } + $(".modal-title").text("Confirm operation of \"" + name+"\""); + $(".button-confirm").text(buttonText); + $(".modal-body").text(bodyText); + $("#delete-elem").attr('action', '/'+funcName+elemName+'/' + elemId); + }); + + $('#deleteModal').on('show.bs.modal', function(e) { + var imageID = $(e.relatedTarget).data('attach-id'); + $('input[name=attachment-id]').val(imageID); + }); + + setTimeout(function () + { + $('.messages').fadeOut('slow'); + }, 10000); +}); diff --git a/backend/static/css/style.css b/backend/static/css/style.css deleted file mode 100644 index 2ff7e4c..0000000 --- a/backend/static/css/style.css +++ /dev/null @@ -1,25 +0,0 @@ -.button { - color: red; - cursor: pointer; - text-decoration: underline; - -} - -.user_table { - border: 2px solid black; - border-collapse: collapse; -} - -.user_table-cell { - border: 1px solid black; - border-collapse: collapse; -} - -.form_user_label { - display: block; - margin-bottom: 20px; -} - -.flash_message { - color: red; -} \ No newline at end of file diff --git a/backend/static/js/main.js b/backend/static/js/main.js deleted file mode 100644 index bb465b8..0000000 --- a/backend/static/js/main.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - - -var btn = document.getElementById('deleteMe'); -var idToDelete = 2; -var jsontoSend = JSON.stringify(idToDelete); - -function show() { - alert(jsontoSend); - var req = new XMLHttpRequest(); - req.open('GET', '/index', true); - req.send(); - window.location.href = '/index'; -} - -btn.addEventListener('click', show); \ No newline at end of file diff --git a/backend/templates/admin_page.html b/backend/templates/admin_page.html index ee13647..1c59654 100644 --- a/backend/templates/admin_page.html +++ b/backend/templates/admin_page.html @@ -1,6 +1,15 @@ -{% extends "base.html" %} +{% extends 'layouts.html' %} + +{% block title_suffix %}Admins managements{% endblock title_suffix %} + +{% block navbar_li %} +
  • Admin page
  • +
  • Logout
  • +{% endblock navbar_li %} + {% block content %} -

    Welcome to the Admin page

    +

    Welcome to the Admins managements

    + Work with users + Work with issues - Work with users -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/backend/templates/base.html b/backend/templates/base.html deleted file mode 100644 index a64db7b..0000000 --- a/backend/templates/base.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - CityView - - - - {% with messages = get_flashed_messages() %} - {% if messages %} - - {% endif %} - {% endwith %} -
    - Main page -
    - {% block content %} - {% endblock %} - - - \ No newline at end of file diff --git a/backend/templates/index.html b/backend/templates/index.html deleted file mode 100644 index bc24add..0000000 --- a/backend/templates/index.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

    Welcome to the CityView

    - Login -
    - Logout -
    - Admin page -{% endblock %} diff --git a/backend/templates/issue.html b/backend/templates/issue.html new file mode 100644 index 0000000..a2d075b --- /dev/null +++ b/backend/templates/issue.html @@ -0,0 +1,101 @@ +{% extends 'layouts.html' %} + + +{% block title_suffix %}Issues managements{% endblock title_suffix %} + +{% block navbar_li %} +
  • Issues page
  • +
  • Users page
  • +
  • Logout
  • +{% endblock navbar_li %} + +{% block content %} +
    +

    {{issue.title }}

    +
    + {% if issue.delete_date %} + Deleted {{ issue.delete_date.strftime('%Y-%m-%d %H:%M') }} + Restore + {% else %} + Delete + {% endif %} + Edit +
    + +
    +
    +
    +
    Description:
    +
    {{ issue.description }}
    +
    Category:
    +
    {{issue.category.category}}
    +
    Owner:
    +
    {{issue.user.alias}}
    +
    Location:
    +
    {{issue.location_lat}}, {{issue.location_lon }}
    +
    Last status:
    +
    {{ issue.status }}
    +
    +
    +
    + {% for attachment in attachments %} +
    + × + +
    + {% endfor %} +
    +
    +
      + {% for history in list_history%} + {% if history[0] == 'change_status' %} + {% if history[1]=='new' %} +
    • {{ history[3]}} new issue created by {{ history[2] }}
    • + {% else %} +
    • {{ history[3]}} status changed to {{ history[1] }} by {{ history[2] }}
    • + {% endif %} + {% else %} +
        +
      • {{ history[3]}} by {{history[1]}}

        "{{history[2]}}"

      • +
      + {% endif%} + {% endfor %} +
    +
    +
    + +{% include 'modal_confirm.html' %} + + + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/issue_modify.html b/backend/templates/issue_modify.html new file mode 100644 index 0000000..5fecc8e --- /dev/null +++ b/backend/templates/issue_modify.html @@ -0,0 +1,40 @@ +{% extends 'layouts.html' %} + + +{% block title_suffix %}Issues edit{% endblock title_suffix %} + +{% block navbar_li %} +
  • Issues page
  • +
  • Logout
  • +{% endblock navbar_li %} + +{% block content %} +

    Issue modifying form: {{ issue.title }}

    +
    +
    + {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} + {{ wtf.form_field(form.title) }} + {{ wtf.form_field(form.status) }} + {{ wtf.form_field(form.description) }} + {{ wtf.form_field(form.location_lat) }} + {{ wtf.form_field(form.location_lon) }} + {{ wtf.form_field(form.category_id) }} + + Edit Attachments + {%if not issue.delete_date %} + Delete + {% else %} + Restore + {% endif %} +
    +
    + Return to issues page + +
    + + {% include 'modal_confirm.html' %} + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/issues_page.html b/backend/templates/issues_page.html new file mode 100644 index 0000000..52ff4ce --- /dev/null +++ b/backend/templates/issues_page.html @@ -0,0 +1,80 @@ +{% extends 'layouts.html' %} + + +{% block title_suffix %}Issues managements{% endblock title_suffix %} + +{% block navbar_li %} +
  • Users page
  • +
  • Logout
  • +{% endblock navbar_li %} + +{% block content %} +
    +

    Search issue: {{form.search(size=40, class="form-control")}} + search by: {{form.search_by(class="form-control") }} + sort by: {{form.order_by(class="form-control")}} + +

    +
    +
    +
    + + + + + + + + + + + + + {% for category, issue, user in issues.items %} + + + + + + + + + + {%if not issue.delete_date %} + + {% else %} + + {% endif %} + + {% endfor %} +
    IdTitleOwnerCategoryStatusOpen dateDelete date
    {{ issue.id }} {{ issue.title }}{{ user }}{{ category}} {{ issue.status }} {{ issue.open_date.strftime('%Y-%m-%d %H:%M') }} + {% if issue.delete_date %} + {{ issue.delete_date.strftime('%Y-%m-%d %H:%M') }} + {% endif %} + Edit Delete + Restore +
    +
    +{% if issues.pages > 1 %} +
    + {% if issues.has_prev %} + << + {% endif %} + {% for page in issues.iter_pages() %} + {% if page == issues.page %} + {{ page }} + {% else %} + {% if not page %}...{%else%} {{ page }} {% endif %} + {% endif %} + {% endfor %} + {% if issues.has_next %} + >> + {% endif %} +
    +{%endif%} + +{% include 'modal_confirm.html' %} + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/layouts.html b/backend/templates/layouts.html new file mode 100644 index 0000000..c7da413 --- /dev/null +++ b/backend/templates/layouts.html @@ -0,0 +1,64 @@ +{% extends "bootstrap/base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% block title %} + City View - {% block title_suffix %}{% endblock title_suffix %} +{% endblock title %} + +{% block body %} + + {% block navbar %} +
    +
    +
    + +
    +
    +
    + {% endblock navbar %} + +
    +
    +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + {% if category == 'message' %} + +
    +
    + +
    +
    +
    + {% block content %} + {% endblock content %} +
    +
    +
    +{% block scripts %} + {{ super() }} + +{%- endblock %} +{% endblock body %} + diff --git a/backend/templates/login_page.html b/backend/templates/login_page.html index 51656e0..93cca6a 100644 --- a/backend/templates/login_page.html +++ b/backend/templates/login_page.html @@ -1,11 +1,17 @@ -{% extends "base.html" %} +{% extends 'layouts.html' %} + + +{% block title_suffix %}Login page{% endblock title_suffix %} + + +{% block navbar_li %} +
  • Admin
  • +{% endblock navbar_li %} + + {% block content %} -
    - {{ form.csrf_token }} - {{ form.email.label }} {{ form.email(size=40) }} -
    - {{ form.password.label }} {{ form.password(size=40) }} -
    - -
    +
    +

    Login page

    + {{ wtf.quick_form(form, action=url_for('login'), method="POST", button_map={'submit_button' : 'success'}) }} +
    {% endblock %} \ No newline at end of file diff --git a/backend/templates/modal_confirm.html b/backend/templates/modal_confirm.html new file mode 100644 index 0000000..b2a2618 --- /dev/null +++ b/backend/templates/modal_confirm.html @@ -0,0 +1,21 @@ + + \ No newline at end of file diff --git a/backend/templates/user_add.html b/backend/templates/user_add.html new file mode 100644 index 0000000..44dcac4 --- /dev/null +++ b/backend/templates/user_add.html @@ -0,0 +1,27 @@ +{% extends 'layouts.html' %} + + +{% block title_suffix %}Users add/edit{% endblock title_suffix %} + +{% block navbar_li %} +
  • Admin
  • +
  • Logout
  • +{% endblock navbar_li %} + +{% block content %} +

    User add form

    +
    +
    + {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} + {{ wtf.form_field(form.name) }} + {{ wtf.form_field(form.alias) }} + {{ wtf.form_field(form.email) }} + {{ wtf.form_field(form.password) }} + {{ wtf.form_field(form.role_id) }} + +
    +
    + Back to the user list +
    +{% endblock %} \ No newline at end of file diff --git a/backend/templates/user_modify.html b/backend/templates/user_modify.html index 4b5ee56..059f9f6 100644 --- a/backend/templates/user_modify.html +++ b/backend/templates/user_modify.html @@ -1,18 +1,39 @@ -{% extends "base.html" %} +{% extends 'layouts.html' %} + + +{% block title_suffix %}Users add/edit{% endblock title_suffix %} + +{% block navbar_li %} +
  • Users page
  • +
  • Logout
  • +{% endblock navbar_li %} + {% block content %} -
    - {{ form.csrf_token }} - {{ form.hidden_tag() }} - {{ form.name.label }} {{ form.name(size=40) }} +

    User modifying form

    +
    + + {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} + + {{ wtf.form_field(form.name) }} + {{ wtf.form_field(form.alias) }} + {{ wtf.form_field(form.email) }} + {% if remove_role_change %} + {{ wtf.form_field(form.role_id) }} + {% endif %} + + {% if not user.delete_date %} + Delete + {% else %} + Restore + {% endif %} +
    - {{ form.alias.label }} {{ form.alias(size=20) }} -
    - {{ form.email.label }} {{ form.email(size=60) }} -
    - {{ form.role_id.label }} {{ form.role_id(size=10) }} -
    - {{ form.delete_date.label }} {{ form.delete_date() }} -
    - - + Return to users page +
    + + {% include 'modal_confirm.html' %} + {% endblock %} \ No newline at end of file diff --git a/backend/templates/user_page.html b/backend/templates/user_page.html index b3cbad0..db4f1a3 100644 --- a/backend/templates/user_page.html +++ b/backend/templates/user_page.html @@ -1,33 +1,86 @@ -{% extends "base.html" %} +{% extends 'layouts.html' %} + + +{% block title_suffix %}Users managements{% endblock title_suffix %} + +{% block navbar_li %} +
  • Issues page
  • +
  • Logout
  • +{% endblock navbar_li %} + {% block content %} -

    Welcome to the Admin page

    - - - - - - - - - - - - +
    +
    +
    +

    Search user: {{form.search(size=40, class="form-control")}} + search by: {{form.search_by(class="form-control") }} + sort by: {{form.order_by(class="form-control")}} + +

    + +
    + +
    +
    +
    +
    IdUserAliasE-mailRoleAvatarDelete date
    + + + + + + + + + + + {% for user, role in users.items %} + + + + + + + + + {% if not user.delete_date %} + + {% else %} + + {% endif %} + + {% endfor %} +
    IdUserAliasE-mailRoleDelete date
    {{ user.id }}{{ user.name }}{{ user.alias }}{{ user.email }}{{ role.role }} + {% if user.delete_date %} + {{ user.delete_date.strftime('%Y-%m-%d %H:%M') }} + {% endif %} + EditDelete + Restore +
    +
    + + {% if users.pages > 1 %} +
    + {% if users.has_prev %} + << + {% endif %} + {% for page in users.iter_pages() %} + {% if page == users.page %} + {{ page }} + {% else %} + {% if not page %}...{%else%} {{ page }} {% endif %} + {% endif %} + {% endfor %} + {% if users.has_next %} + >> + {% endif %} +
    +{%endif%} - {% for user in users %} - - {{ user[0].id }} - {{ user[0].name }} - {{ user[0].alias }} - {{ user[0].email }} - {{ user[1].role }} - {{ user[0].avatar }} - {{ user[0].delete_date }} - Edit - Delete - + {% include 'modal_confirm.html' %} - {% endfor %} - - Add new user -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/backend/views/views.py b/backend/views/views.py index f1f031a..03a6c2b 100644 --- a/backend/views/views.py +++ b/backend/views/views.py @@ -1,115 +1,313 @@ -from datetime import datetime -from flask import flash, render_template, redirect, request, session, url_for -from manage import app, db -from forms.forms import LoginForm, UserForm -from models.users import Role, User -from models.issues import Attachment, Category, Issue, IssueHistory, Status +"""This module generates routes for admin panel""" +from functools import wraps +from urllib import urlencode +from flask_mail import Mail, Message +from flask import (current_app, flash, redirect, request, render_template, + send_from_directory, session, url_for) +from sqlalchemy import and_ -@app.route('/') -@app.route('/index') -def index(): - return render_template('index.html') +from backend.app import app, db, mail +from backend.forms.forms import (IssueForm, LoginForm, SearchUserForm, + SearchIssuesForm, UserForm, UserAddForm) +from backend.models.issues import Attachment, Category, get_all_issue_history, Issue +from backend.models.users import Role, User, user_search -@app.route('/admin') -def admin(): - if 'user_id' in session and session['role_id'] == 1: - return render_template('admin_page.html') - else: - flash('Dont have access ...') - return redirect(url_for('index')) +ROLE_ADMIN = 1 +ROLE_MODERATOR = 2 +ROLE_USER = 3 +MIN_SEARCH_STR = 2 -@app.route('/user_page') -def user_page(): - users = db.session.query(User, Role).filter( - User.role_id == Role.id).order_by(User.id).all() - return render_template('user_page.html', users=users) +PAGINATE_PAGE = 8 -@app.route("/user_modify", methods=['GET', 'POST']) -def user_modify(): - form = UserForm(request.form) +def admin_permissions(function): + """Decorator to check admin rights to access some route.""" - if 'id' in request.args: - user = db.session.query(User).get(request.args.get('id')) - form = UserForm(obj=user) + @wraps(function) + def wrapper(*args, **kwargs): + """Wrapper for routes.""" + if 'user_id' not in session or session['role_id'] != ROLE_ADMIN: + flash("No access", category="danger") + return redirect(url_for('login')) + return function(*args, **kwargs) - if request.method == "GET": - return render_template('user_modify.html', form=form) + return wrapper - elif request.method == "POST": - if form.validate_on_submit(): - if form.id.data: - user = db.session.query(User).get(form.id.data) - user.name = form.name.data - user.alias = form.alias.data - user.email = form.email.data - user.role_id = form.role_id.data +@app.route('/') +@admin_permissions +def admin(): + """Admin page route.""" + return redirect(url_for('issues_page')) - if form.delete_date.data: - user.delete_date = form.delete_date.data - else: - user.delete_date = None - db.session.commit() - flash("user modified") - else: - newuser = User(name=form.name.data, - alias=form.alias.data, - email=form.email.data, - password=None, - role_id=form.role_id.data, - avatar=None, - delete_date=None) - db.session.add(newuser) - db.session.commit() - flash("user added") - return redirect(url_for('user_page')) +@app.route('/userpage', methods=['GET', 'POST']) +@app.route('/userpage/', methods=['GET', 'POST']) +@admin_permissions +def user_page(num_page=1): + """Page with list of users route.""" + form = SearchUserForm(request.args, meta={'csrf': False}) + msg = False + if form.validate(): + search_by = int(request.args.get('search_by')) + order_by = int(request.args.get('order_by')) + search_string = str(request.args.get('search')) + if len(search_string) >= MIN_SEARCH_STR: + condition = user_search(search_string, search_by) else: - flash("wrong data") - return render_template('user_modify.html', form=form) + condition = "" + if search_string != "": + msg = True + order_list = [User.id, User.role_id, User.delete_date] + order = order_list[order_by] -@app.route('/delete_user') -def delete_user(): - if 'id' in request.args: - today = datetime.today().strftime('%Y-%m-%d') - user = db.session.query(User).get(request.args.get('id')) - user.delete_date = today + search_users = db.session.query(User, Role).filter(and_( + User.role_id == Role.id, condition)).order_by(order).paginate( + per_page=PAGINATE_PAGE, page=num_page, error_out=True) + + if msg: + flash("Search string is too small", category="danger") + return render_template('user_page.html', form=form, users=search_users, + get="?" + urlencode(request.args)) + else: + users = db.session.query(User, Role).filter( + User.role_id == Role.id).order_by(User.id).paginate( + per_page=PAGINATE_PAGE, page=num_page, error_out=True) + return render_template('user_page.html', form=form, users=users, + get="?" + urlencode(request.args)) + + +@app.route('/useradd', methods=['GET', 'POST']) +@admin_permissions +def user_add(): + """Page with user add route.""" + route_to = url_for('user_add') + form = UserAddForm(request.form) + + if form.validate_on_submit(): + newuser = User() + newuser.name = form.name.data + newuser.alias = form.alias.data + newuser.role_id = form.role_id.data + newuser.email = form.email.data + newuser.password = form.password.data + db.session.add(newuser) db.session.commit() - flash("user deleted") + subject = "Add User" + msg = Message(app.config['ADMIN_MAIL_SUBJECT_PREFIX'] + ' ' + subject, sender=app.config['ADMIN_MAIL_SENDER'], + recipients=[newuser.email]) + msg.body = """ + From: %s to <%s> + Email: %s + Name: %s + Alias: %s + """ % ( + app.config['ADMIN_MAIL_SUBJECT_PREFIX'], newuser.email, newuser.email, newuser.name, + newuser.alias) + mail.send(msg) + flash("User added and notification", category="success") + return redirect(url_for('user_page')) + + return render_template('user_add.html', form=form, route_to=route_to) + +@app.route('/usermodify/', methods=['GET', 'POST']) +@admin_permissions +def user_modify(users_id): + """Page with user edit route.""" + route_to = url_for('user_modify', users_id=users_id) + user = db.session.query(User).get(users_id) + form = UserForm(request.form, obj=user) + remove_role_change = (users_id != session['user_id']) + + if user.delete_date: + flash("You can't edit the user who was deleted.", category="danger") + elif form.validate_on_submit(): + if (users_id == session['user_id']) and (int(request.form.get('role_id')) != ROLE_ADMIN): + flash("You can't change admin role for yourself.", category="danger") + else: + form.populate_obj(user) + db.session.commit() + flash("User modified") + return redirect(url_for('user_page')) + + return render_template( + 'user_modify.html', + form=form, + user=user, + route_to=route_to, + remove_role_change=remove_role_change) + + +@app.route('/deleteuser/', methods=['POST']) +@admin_permissions +def delete_user(users_id): + """Route for deleting user.""" + user = db.session.query(User).get(users_id) + is_deleted = user.delete() + db.session.commit() + if not is_deleted: + flash("The last admin can't be deleted!", category="danger") + else: + flash("User delete", category="success") + return redirect(url_for('user_page')) + + +@app.route('/restoreuser/', methods=['POST']) +@admin_permissions +def restore_user(users_id): + """Route for restore user.""" + user = db.session.query(User).get(users_id) + user.restore() + db.session.commit() + flash("User restore", category="success") return redirect(url_for('user_page')) @app.route('/login', methods=['GET', 'POST']) def login(): + """Login page route.""" form = LoginForm(request.form) - if request.method == 'GET': - return render_template('login_page.html', form=form) - if request.method == 'POST': - if form.validate_on_submit(): - user = db.session.query(User).filter( - User.email == form.email.data).first() - if user and user.password == form.password.data: - session['user_id'] = user.id - session['role_id'] = user.role_id - flash('Wellcome %s' % user.name) - return redirect(url_for('index')) - else: - flash('Incorrect login/password data...') - return render_template('login_page.html', form=form) + + if form.validate_on_submit(): + user = db.session.query(User).filter( + User.email == form.email.data).first() + if user and not user.delete_date and \ + user.check_password(form.password.data): + session['user_id'] = user.id + session['role_id'] = user.role_id + flash('Welcome %s' % user.name, category="success") + return redirect(url_for('admin')) else: - flash('Incorrect login/password data...') + flash('Incorrect login/password data...', category="danger") return render_template('login_page.html', form=form) + else: + return render_template('login_page.html', form=form) + + return render_template('login_page.html', form=form) @app.route('/logout') def logout(): + """Logout route.""" session.pop('user_id', None) session.pop('role_id', None) - flash("Logout success") - return redirect(url_for('index')) + flash("Successful logout", category="success") + return redirect(url_for('login')) + + +@app.route('/issuespage', methods=['GET', 'POST']) +@app.route('/issuespage/', methods=['GET', 'POST']) +@admin_permissions +def issues_page(num_page=1): + """Issues page route.""" + form = SearchIssuesForm(request.args, meta={'csrf': False}) + condition = None + order = None + if form.validate(): + search_by = int(request.args.get('search_by')) + order_by = int(request.args.get('order_by')) + search_string = str(request.args.get('search')) + + search_list = ['title', 'category', 'description'] + if len(search_string) >= MIN_SEARCH_STR: + search_parameter = '%{}%'.format(search_string) + if search_list[search_by] == 'title': + condition = Issue.title.ilike(search_parameter) + + elif search_list[search_by] == 'description': + condition = Issue.description.ilike(search_parameter) + + else: + condition = Category.category.ilike(search_parameter) + + order_list = [Issue.title, Category.category] + order = order_list[order_by] + + if order and condition is not None: + issues = db.session.query( + Category.category, Issue, User.alias).filter(and_( + Issue.user_id == User.id, Issue.category_id == Category.id, + condition)).order_by(order).paginate( + per_page=PAGINATE_PAGE, page=num_page, error_out=True) + + else: + issues = db.session.query( + Category.category, Issue, User.alias).filter(and_( + Issue.user_id == User.id, Issue.category_id == Category.id)).order_by( + order).paginate(per_page=PAGINATE_PAGE, page=num_page, error_out=True) + + return render_template('issues_page.html', issues=issues, form=form, + get="?" + urlencode(request.args)) + + +@app.route('/issuemodify/', methods=['GET', 'POST']) +@admin_permissions +def issue_modify(issue_id): + """Page with issue edit route.""" + route_to = url_for('issue_modify', issue_id=issue_id) + issue = db.session.query(Issue).get(issue_id) + form = IssueForm(request.form, obj=issue) + + if issue.delete_date: + flash("You can't edit the issue who was deleted.", category="danger") + elif form.validate_on_submit(): + form.populate_obj(issue) + db.session.commit() + flash("Issue modified") + return redirect(url_for('issues_page')) + + return render_template('issue_modify.html', form=form, route_to=route_to, issue=issue) + + +@app.route('/deleteissue/', methods=['POST']) +@admin_permissions +def delete_issue(issue_id): + """Route for deleting issue.""" + issue = db.session.query(Issue).get(issue_id) + issue.delete() + db.session.commit() + flash("Issue delete", category="success") + return redirect(url_for('issues_page')) + + +@app.route('/restoreissue/', methods=['POST']) +@admin_permissions +def restore_issue(issue_id): + """Route for restore issue.""" + issue = db.session.query(Issue).get(issue_id) + issue.restore() + db.session.commit() + flash("Issue restore", category="success") + return redirect(url_for('issues_page')) + + +@app.route('/media/') +def media_dir(url): + return send_from_directory(current_app.config['MEDIA_FOLDER'], url) + + +@app.route('/issue/', methods=['GET']) +@admin_permissions +def issue_info(issue_id): + """Route for issue page""" + issue = db.session.query(Issue).get(issue_id) + list_history = get_all_issue_history(issue_id) + attachments = db.session.query(Attachment).filter(Attachment.issue_id == issue_id).all() + return render_template('issue.html', issue=issue, list_history=list_history, + attachments=attachments) + + +@app.route('/deleteimage', methods=['POST']) +@admin_permissions +def delete_image(): + """Route for deleting attachment.""" + attachment_id = request.form['attachment-id'] + attachment = db.session.query(Attachment).get(attachment_id) + issue_id = attachment.issue_id + attachment.delete() + return redirect(url_for('issue_info', issue_id=issue_id)) diff --git a/client/__init__.py b/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/__init__.py b/client/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/__init__.py b/client/apps/city_issues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/apps.py b/client/apps/city_issues/apps.py new file mode 100644 index 0000000..8831f28 --- /dev/null +++ b/client/apps/city_issues/apps.py @@ -0,0 +1,12 @@ +""" +Adding applications +""" +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class CityIssuesConfig(AppConfig): + """Adds an application""" + name = 'city_issues' diff --git a/client/apps/city_issues/forms/__init__.py b/client/apps/city_issues/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/forms/forms.py b/client/apps/city_issues/forms/forms.py new file mode 100644 index 0000000..7f32efa --- /dev/null +++ b/client/apps/city_issues/forms/forms.py @@ -0,0 +1,417 @@ +"""Forms models""" +from registration.forms import RegistrationForm + +from django import forms +from django.core.validators import MaxValueValidator, MinValueValidator +from django.contrib.auth import get_user_model + +from city_issues.models.issues import Issues, Category, Comments, Statuses +from city_issues.models.users import User + + +class IssueForm(forms.ModelForm): + + class Meta: + model = Issues + fields = ['description', 'category', + 'location_lat', 'location_lon', 'title'] + + title = forms.CharField( + max_length=35, + min_length=3, + widget=forms.TextInput(attrs={'class': 'form-control'}), + ) + + description = forms.CharField( + max_length=350, + min_length=5, + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': '5'}), + ) + + location_lat = forms.FloatField( + widget=forms.TextInput( + attrs={'class': 'form-control', 'readonly': 'readonly'}), + ) + + location_lon = forms.FloatField( + widget=forms.TextInput( + attrs={'class': 'form-control', 'readonly': 'readonly'}), + ) + + category = forms.ModelChoiceField( + queryset=Category.objects.all(), + widget=forms.Select(attrs={'class': 'form-control'}), + empty_label=None + ) + + files = forms.FileField( + required=False, + widget=forms.FileInput(attrs={'accept': 'image/*', 'multiple': True}) + ) + + +class EditIssue(forms.ModelForm): + """Edit issue form.""" + title = forms.CharField( + min_length=5, + max_length=50, + widget=forms.TextInput({'size': 50}), + required=True) + + description = forms.CharField( + min_length=5, + max_length=350, + widget=forms.Textarea(attrs={'rows': 5, 'class': 'issue_description'})) + + category = forms.ModelChoiceField( + queryset=Category.objects.all(), + empty_label=None + ) + + location_lat = forms.FloatField( + validators=[MinValueValidator(-90), MaxValueValidator(90)]) + + location_lon = forms.FloatField( + validators=[MinValueValidator(-180), MaxValueValidator(180)]) + + class Meta: + model = Issues + fields = ['title', 'category', 'location_lat', + 'location_lon', 'description'] + + +class ModEditForm(forms.ModelForm): + """Form edit issue for moderator""" + + class Meta: + model = Issues + fields = ['title', 'description', 'category', + 'location_lat', 'location_lon', 'status'] + + title = forms.CharField( + max_length=35, + min_length=3, + widget=forms.TextInput(attrs={'class': 'form-control'}), + ) + + description = forms.CharField( + max_length=350, + min_length=5, + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': '5'}), + ) + + location_lat = forms.FloatField( + widget=forms.TextInput( + attrs={'class': 'form-control', 'readonly': 'readonly'}), + ) + + location_lon = forms.FloatField( + widget=forms.TextInput( + attrs={'class': 'form-control', 'readonly': 'readonly'}), + ) + + category = forms.ModelChoiceField( + queryset=Category.objects.all(), + widget=forms.Select(attrs={'class': 'form-control'}), + empty_label=None + ) + + status = forms.ChoiceField( + choices=( + ("new", "new"), + ("on moderation", "on moderation"), + ("open", "open"), + ("pending close", "pending close"), + ("closed", "closed"), + ("deleted", "deleted")), + widget=forms.Select(attrs={'class': 'form-control'}) + ) + + +class IssueFilter(forms.Form): + """Issue filter form on map.""" + date_from = forms.DateField( + required=False, + widget=forms.DateInput( + attrs={'type': 'date', 'class': 'form-control'})) + + date_to = forms.DateField( + required=False, + widget=forms.DateInput( + attrs={'type': 'date', 'class': 'form-control'})) + + show_open = forms.BooleanField( + label="Open", + required=False, + initial=True, + widget=forms.CheckboxInput()) + + show_closed = forms.BooleanField( + label="Closed", + required=False, + initial=False, + widget=forms.CheckboxInput()) + + show_new = forms.BooleanField( + label="New", + required=False, + initial=True, + widget=forms.CheckboxInput()) + + show_on_moderation = forms.BooleanField( + label="On moderation", + required=False, + initial=True, + widget=forms.CheckboxInput()) + + show_pending_close = forms.BooleanField( + label="Pending close", + required=False, + initial=False, + widget=forms.CheckboxInput()) + + show_deleted = forms.BooleanField( + label="Deleted", + required=False, + initial=False, + widget=forms.CheckboxInput()) + + category = forms.ModelChoiceField( + queryset=Category.objects.all(), + empty_label="All categories", + required=False, + widget=forms.Select(attrs={'class': 'form-control'})) + + search = forms.CharField( + max_length=20, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Max length 20 chars', + }), + required=False, ) + + +class EditUserForm(forms.ModelForm): + """Edit user form""" + + class Meta: + model = User + fields = ['name', 'alias', 'email'] + + name = forms.CharField( + max_length=25, + min_length=3, + widget=forms.TextInput(attrs={'class': 'form-control'}), + ) + + alias = forms.CharField( + max_length=20, + min_length=3, + widget=forms.TextInput(attrs={'class': 'form-control'}), + ) + + email = forms.EmailField( + max_length=50, + min_length=4, + widget=forms.EmailInput(attrs={'class': 'form-control'}), + ) + + current_password = forms.CharField( + required=False, + min_length=3, + widget=forms.PasswordInput(attrs={'class': 'form-control'}) + ) + + new_password = forms.CharField( + required=False, + max_length=50, + min_length=3, + widget=forms.PasswordInput(attrs={'class': 'form-control'}) + ) + + confirm_password = forms.CharField( + required=False, + max_length=50, + min_length=3, + widget=forms.PasswordInput(attrs={'class': 'form-control'}) + ) + + def clean(self): + cleaned_data = super(EditUserForm, self).clean() + current_password = cleaned_data.get('current_password') + new_password = cleaned_data.get('new_password') + confirm_password = cleaned_data.get('confirm_password') + + user = User.objects.get(id=self.instance.id) + + if current_password or new_password or confirm_password: + self.check_passwords(user, current_password, new_password, + confirm_password) + + return cleaned_data + + def check_current_password(self, user, current_password): + if not user.check_password(current_password): + self._errors['current_password'] = self.error_class( + ['Incorrect current password']) + del self.cleaned_data['confirm_password'] + print user.check_password(current_password) + return False + else: + return True + + def check_passwords(self, user, current_password, new_password, + confirm_password): + if not self.check_current_password(user, current_password): + return None + + if (confirm_password or new_password) and ( + new_password != confirm_password): + self._errors['confirm_password'] = self.error_class( + ['Passwords do not match.']) + del self.cleaned_data['confirm_password'] + + if not new_password and not confirm_password and self.check_current_password(user, current_password): + self._errors['new_password'] = self.error_class( + ['Fields is required']) + self._errors['confirm password'] = self.error_class( + ['Fields is required']) + + +class IssueSearchForm(forms.Form): + """Issue search form.""" + search = forms.CharField( + min_length=2, + max_length=100, + label='', + widget=forms.TextInput(attrs={ + 'size': '60%', + 'class': 'form-control', + 'style': 'border-color: #31b0d5;', + }), + required=False) + order_by = forms.CharField( + widget=forms.HiddenInput(), + required=False, + initial='title') + reverse = forms.CharField( + widget=forms.HiddenInput(), + required=False) + page = forms.IntegerField( + widget=forms.HiddenInput(), + required=False) + + +class IssueFormEdit(IssueForm): + + status = forms.ChoiceField( + choices=( + ("new", "new"), + ("on moderation", "on moderation"), + ("open", "open"), + ("pending close", "pending close"), + ("closed", "closed"), + ("deleted", "deleted")), + widget=forms.Select(attrs={'class': 'form-control'}) + ) + + class Meta: + model = Issues + fields = ['description', 'category', + 'location_lat', 'location_lon', 'title', 'status'] + + +class IssueFormEditWithoutStatus(IssueForm): + + class Meta: + model = Issues + fields = ['description', 'category', + 'location_lat', 'location_lon', 'title', ] + + +class CommentsOnMapForm(forms.Form): + """Map comments form.""" + + comment = forms.CharField( + label='', + required=False, + min_length=1, + max_length=350, + widget=forms.Textarea(attrs={'rows': 2})) + + status = forms.ChoiceField( + label='', + required=True, + initial="public", + choices=( + ("public", "public"), + ("private", "private"), + ("internal", "internal")), + widget=forms.RadioSelect( + attrs={'class': 'comments-status-buttons'}) + ) + + class Meta: + model = Comments + fields = ['comment'] + + +class InternalCommentsForm(forms.Form): + """Internal comments form.""" + + comment = forms.CharField( + label='', + min_length=1, + max_length=100, + widget=forms.TextInput({'class': 'form-control', 'placeholder': 'Type Message ...'})) + + +User = get_user_model() + + +class RegisterUserForm(RegistrationForm): + """Registration form""" + + alias = forms.CharField( + required=True, + max_length=20, + min_length=3, + ) + + name = forms.CharField( + required=False, + max_length=25, + min_length=3, + ) + + class Meta: + model = User + fields = ("email", + "alias", "name", "password1", + "password2") + + +class ModCommentForm(forms.ModelForm): + """Moderator comments form""" + + comment = forms.CharField( + label='', + required=True, + min_length=1, + max_length=250, + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Type Message ...'})) + + status = forms.ChoiceField( + label='', + required=False, + initial="public", + choices=( + ("public", "public"), + ("private", "private"), + ("internal", "internal")), + widget=forms.Select( + attrs={'class': 'form-control'})) + + class Meta: + model = Comments + fields = ['comment', 'status'] diff --git a/client/apps/city_issues/management/__init__.py b/client/apps/city_issues/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/management/commands/__init__.py b/client/apps/city_issues/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/management/commands/runall.py b/client/apps/city_issues/management/commands/runall.py new file mode 100644 index 0000000..7b59dfc --- /dev/null +++ b/client/apps/city_issues/management/commands/runall.py @@ -0,0 +1,21 @@ +""" +Basically the idea was to connect the 2 apps and run them +with Django web server +""" +from django.core.management.commands.runserver import BaseRunserverCommand +from django.core.servers.basehttp import get_internal_wsgi_application + +from werkzeug.wsgi import DispatcherMiddleware + +from backend.app import app as admin_app + +city_issues = get_internal_wsgi_application() + + +class Command(BaseRunserverCommand): + """ + Runs the apps with Django web server + """ + def get_handler(self, *args, **options): + application = DispatcherMiddleware(city_issues, {'/admin': admin_app}) + return application diff --git a/client/apps/city_issues/migrations/0001_initial.py b/client/apps/city_issues/migrations/0001_initial.py new file mode 100644 index 0000000..eb52be6 --- /dev/null +++ b/client/apps/city_issues/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.7 on 2017-11-22 14:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, + primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField(unique=True)), + ('alias', models.TextField(blank=True, null=True)), + ('email', models.EmailField(max_length=50, unique=True)), + ('hashed_password', models.TextField( + blank=True, max_length=256, null=True)), + ('avatar', models.ImageField(blank=True, null=True, upload_to=b'')), + ('delete_date', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'db_table': 'users', + 'managed': False, + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, + primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.TextField()), + ], + options={ + 'db_table': 'roles', + 'managed': False, + }, + ), + ] diff --git a/client/apps/city_issues/migrations/__init__.py b/client/apps/city_issues/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/mixins.py b/client/apps/city_issues/mixins.py new file mode 100644 index 0000000..bde2335 --- /dev/null +++ b/client/apps/city_issues/mixins.py @@ -0,0 +1,9 @@ +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator + + +class LoginRequiredMixin(object): + + @method_decorator(login_required) + def dispatch(self, *args, **kwargs): + return super(LoginRequiredMixin, self).dispatch(*args, **kwargs) diff --git a/client/apps/city_issues/models/__init__.py b/client/apps/city_issues/models/__init__.py new file mode 100644 index 0000000..b95c78e --- /dev/null +++ b/client/apps/city_issues/models/__init__.py @@ -0,0 +1,2 @@ +from .users import User +from .issues import Issues, Attachments, Category, IssueHistory, Statuses, Comments diff --git a/client/apps/city_issues/models/issues.py b/client/apps/city_issues/models/issues.py new file mode 100644 index 0000000..0e18de7 --- /dev/null +++ b/client/apps/city_issues/models/issues.py @@ -0,0 +1,332 @@ +""" +Django models +""" +from __future__ import unicode_literals + +import os +import time +from datetime import date, datetime, time + +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.utils.timezone import make_aware +from django.core.urlresolvers import reverse + +ROLE_ADMIN = 1 +ROLE_MODERATOR = 2 +ROLE_USER = 3 + + +class Attachments(models.Model): + """ + Attachment table in the database. + """ + + def get_file_path(self, filename): + # pylint: disable=no-member + folder = self.issue.title + # pylint: enable=no-member + return os.path.join('uploads', folder, filename) + + def delete(self, *args, **kwargs): + # pylint: disable=no-member + storage, path = self.image_url.storage, self.image_url.path + super(Attachments, self).delete(*args, **kwargs) + directory_path = os.path.abspath(os.path.join(path, os.pardir)) + + head, tail = os.path.split(self.image_url.path) + thumb_name = "thumb-{}".format(tail) + thumb_storage, thumb_path = self.image_url.storage, os.path.join(directory_path, thumb_name) + # pylint: enable=no-member + + storage.delete(path) + thumb_storage.delete(thumb_path) + if os.path.isdir(directory_path) and not os.listdir(directory_path): + os.rmdir(directory_path) + + issue = models.ForeignKey('Issues', models.DO_NOTHING, + blank=True, null=True) + image_url = models.ImageField( + blank=True, null=True, upload_to=get_file_path) + + class Meta: + app_label = 'city_issues' + managed = False + db_table = 'attachments' + + +class Category(models.Model): + """ + Category table in the database. + """ + category = models.TextField(blank=True, null=True) + favicon = models.TextField(blank=True, null=True) + + def __unicode__(self): + return u'{0}'.format(self.category) + + class Meta: + app_label = 'city_issues' + managed = False + db_table = 'category' + + +class Statuses(models.Model): + """ + Status table in the database. + """ + status = models.TextField(blank=True, null=True) + + def __unicode__(self): + return u'{0}'.format(self.status) + + class Meta: + app_label = 'city_issues' + managed = False + db_table = 'statuses' + + +class IssueHistory(models.Model): + """ + IssueHistory table in the database. + """ + STATUS_ID_NEW = Statuses.objects.get(id=1) + + user = models.ForeignKey('User', models.DO_NOTHING, + blank=True, null=True) + issue = models.ForeignKey('Issues', models.DO_NOTHING, + blank=True, null=True) + status = models.ForeignKey('Statuses', models.DO_NOTHING, + blank=True, null=True, default=STATUS_ID_NEW) + transaction_date = models.DateTimeField(blank=True, null=True, + auto_now_add=True) + + class Meta: + app_label = 'city_issues' + managed = False + db_table = 'issue_History' + + +class Issues(models.Model): + """ + Issues table in the database. + """ + title = models.TextField(blank=True, null=True) + user = models.ForeignKey('User', models.DO_NOTHING, + blank=True, null=True) + category = models.ForeignKey('Category', models.DO_NOTHING) + location_lat = models.FloatField(blank=True, null=True) + location_lon = models.FloatField(blank=True, null=True) + status = models.TextField(blank=True, null=True, default='new') + description = models.TextField(blank=True, null=True) + open_date = models.DateTimeField(blank=True, null=True, auto_now_add=True) + close_date = models.DateTimeField(blank=True, null=True) + delete_date = models.DateTimeField(blank=True, null=True) + + class Meta: + app_label = 'city_issues' + managed = False + db_table = 'issues' + + def get_attachments(self): + return Attachments.objects.filter(issue=self.id) + + def get_role_based_query(self, request): + """Return issues based on role and author.""" + if request.user.is_anonymous(): + query = Issues.objects.filter(status__in=["open", "closed"]) + if request.user.is_authenticated() and request.user.role.id not in (ROLE_ADMIN, ROLE_MODERATOR): + query = Issues.objects.filter( + Q(status__in=["open", "closed"]) | Q(user=request.user.id)).exclude(status="deleted") + if request.user.is_authenticated() and request.user.role.id in (ROLE_ADMIN, ROLE_MODERATOR): + query = Issues.objects.all() + return query + + def issue_filter(self, form, role_based_query): + """Filter issue by form data.""" + kwargs = {} + map_date_from = form.cleaned_data.get('date_from') + map_date_to = form.cleaned_data.get('date_to') + status_arr = form.data.get('status_arr').split(",") + category = form.data.get('category') + search = form.cleaned_data.get('search') + + if status_arr: + kwargs["status__in"] = status_arr + + date_from = make_aware(datetime(1970, 1, 1,)) + date_to = make_aware(datetime.now()) + + if map_date_from: + date_from = make_aware( + datetime.combine(map_date_from, time.min)) + if map_date_to: + date_to = make_aware(datetime.combine(map_date_to, time.max)) + + kwargs["open_date__range"] = (date_from, date_to) + + if category: + kwargs['category'] = category + + query = role_based_query.filter(**kwargs) + + if search: + query = role_based_query.filter(**kwargs).filter( + Q(title__icontains=search) | Q(description__icontains=search)) + return query + + def get_issue_data_by_id(self, request, issue_id): + """Get single issue data.""" + dict_of_actions = self.get_actions_list(request, issue_id) + + attachments_query = list( + Attachments.objects.filter(issue=issue_id).values()) + + comments_list = Comments() + comments_query = comments_list.get_comments( + issue_id, dict_of_actions['list_of_comments_statuses']) + + images_urls = [item['image_url'] for item in attachments_query] + checked_img_urls = [] + + for img in images_urls: + if img and os.path.isfile(os.path.join(settings.MEDIA_ROOT, img)): + imgurl = img.split('/') + imgurl[-1] = 'thumb-' + imgurl[-1] + img = settings.MEDIA_URL + ('/').join(imgurl) + checked_img_urls.append(img) + + issue_obj = Issues.objects.filter( + pk=issue_id).select_related("category") + unpacked_issue_obj = issue_obj[0] + + issue_query = list(issue_obj.values( + "title", + "user", + "category", + "location_lat", + "location_lon", + "status", + "description", + "open_date", + "close_date", + "delete_date", + "category__category",)) + + issue_dict = issue_query[0] + issue_dict['images_urls'] = checked_img_urls + issue_dict['dict_of_actions'] = dict_of_actions + issue_dict['comments'] = comments_query + + issue_dict['open_date'] = convert_date(issue_dict['open_date']) + issue_dict['close_date'] = convert_date(issue_dict['close_date']) + issue_dict['delete_date'] = convert_date( + issue_dict['delete_date']) + + return issue_query + + def get_actions_list(self, request, issue_id): + """Return list of allowed actions with issue.""" + list_of_comments_statuses = ['public'] + list_of_actions = [] + + if request.user.is_authenticated(): + + issue = Issues.objects.get(pk=issue_id) + + user_is_admin_or_moderator = request.user.role.id in ( + ROLE_ADMIN, ROLE_MODERATOR) + + user_is_issue_owner = (issue.user_id == request.user.id) + + if user_is_admin_or_moderator: + list_of_actions.append('edit') + list_of_comments_statuses.append('private') + list_of_comments_statuses.append('internal') + + if issue.status in ('new', 'on moderation'): + list_of_actions.append("open") + + if issue.status in ('open', 'pending close'): + list_of_actions.append("closed") + + if issue.status != 'deleted': + list_of_actions.append("deleted") + else: + if user_is_issue_owner: + list_of_comments_statuses.append('private') + + if issue.status in ('new', 'on moderation'): + list_of_actions.append("edit") + if issue.status == 'open': + list_of_actions.append("pending close") + + dict_of_actions = { + 'list_of_comments_statuses': list_of_comments_statuses, + 'list_of_actions': list_of_actions + } + + return dict_of_actions + + def get_absolute_url(self): + return reverse("mod_edit", kwargs={"pk": self.pk}) + + def mod_delete(self): + """Setting deleting date for issue""" + if not self.delete_date: + self.delete_date = datetime.now() + return True + return False + + def mod_restore(self): + """Restoring issue from deletion""" + if self.delete_date: + self.delete_date = None + return True + return False + + +class Comments(models.Model): + """ + Issues table in the database. + """ + user = models.ForeignKey('User', models.DO_NOTHING, + blank=True, null=True) + issue = models.ForeignKey('Issues', models.DO_NOTHING) + comment = models.TextField(max_length=400, null=False, blank=False) + date_public = models.DateTimeField(auto_now_add=True) + status = models.TextField(null=False) + pre_deletion_status = models.TextField() + + class Meta: + """...""" + app_label = 'city_issues' + managed = False + db_table = 'comments' + + def get_comments(self, issue_id, allowed_statuses_to_return): + """Gets last three comments.""" + kwargs = { + 'issue': issue_id, + 'status__in': allowed_statuses_to_return, + } + comments_query = list( + Comments.objects.filter(**kwargs).select_related("user").order_by('date_public').values( + "user__alias", + "comment", + "date_public", + "status", + )[::-1]) + + for comment in comments_query: + comment['date_public'] = convert_date(comment['date_public']) + + return comments_query + + +def convert_date(obj): + """Converts data field from database to json acceptable format""" + if isinstance(obj, (date, datetime)): + return obj.isoformat(str(" ")) + return obj diff --git a/client/apps/city_issues/models/users.py b/client/apps/city_issues/models/users.py new file mode 100644 index 0000000..4fff201 --- /dev/null +++ b/client/apps/city_issues/models/users.py @@ -0,0 +1,124 @@ +""" +Django models +""" +from __future__ import unicode_literals + +import datetime + +from django.contrib.auth.models import AbstractBaseUser +from django.contrib.auth.hashers import make_password +from django.db import models +from passlib.handlers.django import django_bcrypt + +from city_issues.user_managers import UserManager + + +class Role(models.Model): + """ + Roles table in the database + """ + role = models.TextField() + app_label = 'city_issues' + + class Meta: + managed = False + db_table = 'roles' + + +ROLE_ADMIN = Role.objects.get(id=1) +ROLE_MODERATOR = Role.objects.get(id=2) +ROLE_USER = Role.objects.get(id=3) + + +class User(AbstractBaseUser): + """ + Users table in the database + """ + name = models.TextField( + max_length=50, + blank=True, + null=True) + alias = models.TextField( + max_length=25, + unique=True) + email = models.EmailField( + max_length=50, + unique=True) + hashed_password = models.TextField( + max_length=256, + blank=True, + null=True) + role = models.ForeignKey( + 'Role', + default=ROLE_USER) + avatar = models.ImageField( + blank=True, + null=True) + delete_date = models.DateTimeField( + blank=True, + null=True) + last_login = models.DateTimeField( + blank=True, + null=True) + + # Connects a custom user manager + objects = UserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['alias', 'name'] + + def get_full_name(self): + return self.name + + def get_short_name(self): + return self.name + + @property + def password(self): + return self.hashed_password + + @password.setter + def password(self, raw_password): + self.set_password(raw_password) + + def set_password(self, raw_password): + self.hashed_password = make_password(raw_password) + self._password = raw_password + + def check_password(self, raw_password): + """Checking the password form database.""" + return django_bcrypt.verify(raw_password, self.hashed_password) + + @property + def is_active(self): + return not self.delete_date + + @is_active.setter + def is_active(self, value): + self.delete_date = None if value else datetime.datetime.now() + + @property + def is_staff(self): + return self.role == ROLE_MODERATOR + + @is_staff.setter + def is_staff(self, value): + if value: + self.role = ROLE_MODERATOR + else: + self.role = ROLE_USER + + @property + def is_superuser(self): + return self.role == ROLE_ADMIN + + @is_superuser.setter + def is_superuser(self, value): + if value: + self.role = ROLE_ADMIN + else: + self.role = ROLE_USER + + class Meta: + managed = False + db_table = 'users' diff --git a/client/apps/city_issues/static/city_issues/css/style.css b/client/apps/city_issues/static/city_issues/css/style.css new file mode 100644 index 0000000..f8701d3 --- /dev/null +++ b/client/apps/city_issues/static/city_issues/css/style.css @@ -0,0 +1,332 @@ +.map_container { + min-height: 600px; + height: calc(100vh - 52px); +} + +.issue_container { + box-sizing: border-box; + position: absolute; + top: 50px; + right: 0; + + display: none; + box-sizing: border-box; + width: 360px; + min-height: 600px; + height: calc(100% - 50px); + overflow-y: auto; + + background-color: #fff; + box-shadow: -5px 0 5px rgba(0, 0, 0, 0.3); + + z-index: 999; +} + +.issue_img-box { + width: 360px; + height: 225px; +} + +.issue_img-box > .issue_img { + width: 100%; + height: 100%; +} + +.issue_img-box-close-btn { + position: absolute; + top: 4px; + right: 4px; + + display: block; + width: 25px; + height: 25px; + + background-image: url("/static/city_issues/img/close_cross.png"); + background-size: contain; + background-repeat: no-repeat; + + cursor: pointer; + z-index: 1999; + +} + + +#carousel-example-generic img { + height: 100%; + width: 100%; +} + +.carousel-inner .item{ + width: auto; + height: 225px; + max-height: 225px; +} + +.issue_form-box { + position: absolute; + background-color: #fff; + box-shadow: 0 0 3px 0 #000; + border-radius: 4px; + z-index: 1000; +} + +.issue_form-container { + padding: 10px; +} + +.message_box { + display: none; +} + +.message_box ul { + list-style: none; + margin: 0; + padding: 0; +} + +.message_box .some_error { + + font-size: 16px; + color: red; +} + +.issue_form-container { + display: none; +} + +.issue_filter-form-show-btn { + background-color: #fff; + border: 4px solid #fff; + border-radius: 4px; + box-shadow: 0 1px 5px rgba(0,0,0,0.65); +} + +.issue_filter-form-show-btn:hover { + background-color: #f4f4f4; + border-color: #f4f4f4; +} + +.width.map-width { + margin: 0; +} + +.navbar { + margin-bottom: 0; +} + +.issue_header-box { + padding: 20px; + + color: #fff; + background-color: #4285F4; +} + +.issue_description { + padding: 10px 20px 0px 20px; +} + +.issue_buttons-box { + padding: 5px 20px 5px 20px; +} + + +.issue_comments-box { + padding: 5px 20px 5px 20px; +} + +.issue_comments { + max-height: 350px; + margin: 0; + margin-bottom: 10px; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.issue_comments li { + padding-bottom: 10px; +} + +.issue_category { + margin: 0; +} + +.form-group--checkbox { + margin-bottom: 10px; + + border-top: 1px solid #aaa; + border-bottom: 1px solid #aaa; +} + +.form-group--checkbox .form-group { + margin-top: 2px; + margin-bottom: 2px; +} + +.issue_comment-header { + width: 95%; + + font-size: 12px; + color: #333; + font-weight: bold; +} + +.issue_comment-author { + color: red; +} + + +.issue_comments-form-btn { + margin-top: 5px; + margin-bottom: 10px; +} + +.issue_buttons-box a { + margin-bottom: 3px; +} + +.issue_action-hide { + display: none; +} + +.modal-dialog.modal-width{ + width: 250px; +} + +.form-group--checkbox label[for=id_show_open] { + text-shadow: 0 0 1px #222; +} + +.form-group--checkbox label[for=id_show_new] { + color: blue; +} + +.form-group--checkbox label[for=id_show_on_moderation] { + color: orange; +} + +.form-group--checkbox label[for=id_show_pending_close] { + color: red; +} + +.form-group--checkbox label[for=id_show_closed] { + color: gray; +} + +.form-group--checkbox label[for=id_show_deleted] { + color: black; +} + +.wrapper ul.messages { + top: 15px; + right: 25%; + + background-color: transparent; +} + +.comments-status-buttons { + list-style: none; + margin: 0; + padding: 0; +} + +.comments-status-buttons li { + display: inline-block; + vertical-align: top; + + margin-right: 10px; +} + +.issue_comment-text { + position: relative; + width: 95%; + margin-top: 10px; + padding: 10px; + + background-color: #eee; + + border-radius: 5px; + + white-space: pre-wrap; +} + +.issue_comment-text::before { + content: ""; + position: absolute; + top: -9px; + left: 10%; + + display: inline-block; + width: 0; + height: 0; + + border-style: solid; + border-width: 0 8px 10px 8px; + border-color: transparent transparent #eee transparent; +} + + +.issue_comment-text--private { + background-color: #66ccff; +} + +.issue_comment-text--private::before { + content: ""; + position: absolute; + top: -9px; + left: 10%; + + display: inline-block; + width: 0; + height: 0; + + border-style: solid; + border-width: 0 8px 10px 8px; + border-color: transparent transparent #66ccff transparent; +} + +.issue_comment-text--internal { + background-color: #ffcc99; +} + +.issue_comment-text--internal::before { + content: ""; + position: absolute; + top: -9px; + left: 10%; + + display: inline-block; + width: 0; + height: 0; + + border-style: solid; + border-width: 0 8px 10px 8px; + border-color: transparent transparent #ffcc99 transparent; +} + +.comments-status-buttons label[for=id_status_1] { + color: #66ccff; +} + +.comments-status-buttons label[for=id_status_2] { + color: #ffcc99; +} + +.issue_comments-form textarea{ + box-sizing: border-box; + width: 100%; + padding: 10px; + + border: none; + border-radius: 5px; + + background-color: #eee; +} + +.issue_comments-form textarea.textarea--private{ + background-color: #66ccff; +} + +.issue_comments-form textarea.textarea--internal{ + background-color: #ffcc99; +} \ No newline at end of file diff --git a/client/apps/city_issues/static/city_issues/img/category_1_marker-icon.png b/client/apps/city_issues/static/city_issues/img/category_1_marker-icon.png new file mode 100644 index 0000000..bed5e39 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/category_1_marker-icon.png differ diff --git a/client/apps/city_issues/static/city_issues/img/category_2_marker-icon.png b/client/apps/city_issues/static/city_issues/img/category_2_marker-icon.png new file mode 100644 index 0000000..dfce30b Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/category_2_marker-icon.png differ diff --git a/client/apps/city_issues/static/city_issues/img/category_3_marker-icon.png b/client/apps/city_issues/static/city_issues/img/category_3_marker-icon.png new file mode 100644 index 0000000..48f7efd Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/category_3_marker-icon.png differ diff --git a/client/apps/city_issues/static/city_issues/img/category_4_marker-icon.png b/client/apps/city_issues/static/city_issues/img/category_4_marker-icon.png new file mode 100644 index 0000000..3f3a97b Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/category_4_marker-icon.png differ diff --git a/client/apps/city_issues/static/city_issues/img/close_cross.png b/client/apps/city_issues/static/city_issues/img/close_cross.png new file mode 100644 index 0000000..ce4eb18 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/close_cross.png differ diff --git a/client/apps/city_issues/static/city_issues/img/icon_active.png b/client/apps/city_issues/static/city_issues/img/icon_active.png new file mode 100644 index 0000000..9e6b54e Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/icon_active.png differ diff --git a/client/apps/city_issues/static/city_issues/img/marker-shadow.png b/client/apps/city_issues/static/city_issues/img/marker-shadow.png new file mode 100644 index 0000000..84c5808 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/marker-shadow.png differ diff --git a/client/apps/city_issues/static/city_issues/img/no-image.png b/client/apps/city_issues/static/city_issues/img/no-image.png new file mode 100644 index 0000000..4e15f2d Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/no-image.png differ diff --git a/client/apps/city_issues/static/city_issues/img/status_closed.png b/client/apps/city_issues/static/city_issues/img/status_closed.png new file mode 100644 index 0000000..40cc0b1 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/status_closed.png differ diff --git a/client/apps/city_issues/static/city_issues/img/status_deleted.png b/client/apps/city_issues/static/city_issues/img/status_deleted.png new file mode 100644 index 0000000..747c0b4 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/status_deleted.png differ diff --git a/client/apps/city_issues/static/city_issues/img/status_new.png b/client/apps/city_issues/static/city_issues/img/status_new.png new file mode 100644 index 0000000..c425d47 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/status_new.png differ diff --git a/client/apps/city_issues/static/city_issues/img/status_on_moderation.png b/client/apps/city_issues/static/city_issues/img/status_on_moderation.png new file mode 100644 index 0000000..03bbbe9 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/status_on_moderation.png differ diff --git a/client/apps/city_issues/static/city_issues/img/status_pending_close.png b/client/apps/city_issues/static/city_issues/img/status_pending_close.png new file mode 100644 index 0000000..fadd576 Binary files /dev/null and b/client/apps/city_issues/static/city_issues/img/status_pending_close.png differ diff --git a/client/apps/city_issues/static/city_issues/js/app.js b/client/apps/city_issues/static/city_issues/js/app.js new file mode 100644 index 0000000..6233539 --- /dev/null +++ b/client/apps/city_issues/static/city_issues/js/app.js @@ -0,0 +1,23 @@ +$( document ).ready(function() { + $('#deletion').on('show.bs.modal', function(e) { + var elemId = $(e.relatedTarget).data('elem-id'); + var issueId = $(e.relatedTarget).data('issue-id'); + var funcName = $(e.relatedTarget).data('func-name'); + var elemName = $(e.relatedTarget).data('elem-name'); + if (funcName == 'delete'){ + buttonText ='Delete'; + bodyText = "Please click "+buttonText+" button if you want to delete data."; + $(".button-confirm").addClass('btn-danger'); + } + else { + buttonText ='Restore'; + bodyText = "Please click "+buttonText+" button if you want to restore data."; + $(".button-confirm").addClass('btn-success'); + + } + $(".modal-title").text("Confirm operation"); + $(".button-confirm").text(buttonText); + $(".modal-body").text(bodyText); + $("#delete-comment").attr('action', '/'+funcName+elemName+'/'+ issueId +'/'+ elemId+'/'); + }); +}); diff --git a/client/apps/city_issues/static/city_issues/js/edit_on_map.js b/client/apps/city_issues/static/city_issues/js/edit_on_map.js new file mode 100644 index 0000000..3e56c11 --- /dev/null +++ b/client/apps/city_issues/static/city_issues/js/edit_on_map.js @@ -0,0 +1,65 @@ +function IssueMap() { + this.map = L.map('map'); + this.lat = localStorage.getItem('lat'); + this.lng = localStorage.getItem('lng'); + this.scale = 16; + + if (this.lat && this.lng) { + this.map.setView([this.lat, this.lng], this.scale); + } else { + this.map.locate({setView: true, maxZoom: 50}); + } + + this.layer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(this.map); + + this.marker = L.marker([this.lat, this.lng], {draggable: true}).addTo(this.map); + + + this.map.on('click', ($.proxy(this.getLocation, this))); + this.marker.on('dragend', ($.proxy(this.getDragLocation, this))); + $('a.del-img').on('click', ($.proxy(this.onDeleteImage, this))); + $('#issue_form-submit').on('click', function() { + localStorage.setItem('updateStatus', $("#id_status").val()); + }); + +} + +IssueMap.prototype.getLocation = function (e) { + if (this.map.hasLayer(this.marker)) + this.map.removeLayer(this.marker); + var location = e.latlng; + this.marker = L.marker(location, {draggable: true}).addTo(this.map); + this.marker.off('dragend'); + this.marker.on('dragend', ($.proxy(this.getDragLocation, this))); + $.proxy(this.setInputLocation(location), this); + +}; + +IssueMap.prototype.getDragLocation = function (e) { + if (this.map.hasLayer(this.marker)) + this.map.removeLayer(this.marker); + this.marker = e.target; + var location = this.marker.getLatLng(); + this.marker.setLatLng(location, {draggable: true}).addTo(this.map); + $.proxy(this.setInputLocation(location), this); +}; + + +IssueMap.prototype.setInputLocation = function (location) { + $("input[name=location_lat]").val(location.lat); + $("input[name=location_lon]").val(location.lng); + localStorage.setItem('lat', location.lat); + localStorage.setItem('lng', location.lng); +}; + +IssueMap.prototype.onDeleteImage = function (event) { + var imageID = $(event.target).attr('data-attach-id'); + $('input[name=attachment-id]').val(imageID); +}; + +$(function () { + new IssueMap(); +}); + + + diff --git a/client/apps/city_issues/static/city_issues/js/main.js b/client/apps/city_issues/static/city_issues/js/main.js new file mode 100644 index 0000000..85e19e5 --- /dev/null +++ b/client/apps/city_issues/static/city_issues/js/main.js @@ -0,0 +1,478 @@ +(function(){ + +function IssueMap(elementId) { + var current = this; + this.map = L.map(elementId); + this.issueDescriptionBox = new IssueDescription(this, "mapid", "issue_container", "issue_close"); + this.issueDescriptionBox.addHandler(); + this.currentMarker = undefined; + this.markers = undefined; + this.statusRawArr = [ + document.querySelector("#id_show_closed"), + document.querySelector("#id_show_open"), + document.querySelector("#id_show_new"), + document.querySelector("#id_show_on_moderation"), + document.querySelector("#id_show_deleted"), + document.querySelector("#id_show_pending_close") + ]; + + IssueMap.prototype.setFilterFromBtn = function(filterFormBtnId) { + current.filterFormBtn = document.querySelector(filterFormBtnId); + }; + + IssueMap.prototype.setFilterFromCloseBtn = function(filterFormCloseBtnId) { + current.filterFormCloseBtnId = document.querySelector(filterFormCloseBtnId); + }; + + IssueMap.prototype.setFilterFromShowBtn = function(filterFormShowBtnId) { + current.filterFormShowBtnId = document.querySelector(filterFormShowBtnId); + }; + + + IssueMap.prototype.setViewPoint = function(latitude, longitude, scale) { + if (localStorage.getItem('lat') && localStorage.getItem('lng')) { + this.map.setView([localStorage.getItem('lat'), localStorage.getItem('lng')], scale); + } else { + this.map.setView([latitude, longitude], scale); + } + + }; + + + IssueMap.prototype.addMapLayer = function(mapLink, mapZoom, mapAttribute) { + L.tileLayer(mapLink, {maxZoom: mapZoom, attribution: mapAttribute}).addTo(this.map); + }; + + + IssueMap.prototype.iconCreate = function(category, status) { + var underscoredStatusShadow = '/static/city_issues/img/status_' + status.replace(/ /g, '_') + '.png'; + if (status == "open") { + underscoredStatusShadow = "/static/city_issues/img/marker-shadow.png"; + } + var MarkerIcon = L.Icon.extend({ + options: { + customId: "", + customStatus: "", + iconUrl: '/static/city_issues/img/category_' + category + '_marker-icon.png', + shadowUrl: underscoredStatusShadow, + iconSize: [30, 30], + iconAnchor: [5, 35], + popupAnchor: [1, -34], + shadowSize: [40, 40], + shadowAnchor: [10, 40], + }}); + + return new MarkerIcon(); + }; + + IssueMap.prototype.showIssueDetails = function(issueId, event) { + localStorage.setItem('lat', event.latlng.lat); + localStorage.setItem('lng', event.latlng.lng); + current.issueDescriptionBox.getIssueById(issueId); + }; + + IssueMap.prototype.insertAllMarkers = function(jsonData) { + if (current.markers) { + current.map.removeLayer(current.markers); + } + + current.markers = L.markerClusterGroup(); + + jsonData.forEach(function(key) { + var issue = JSON.parse(key); + var underscoredStatus = '/static/city_issues/img/status_' + issue.fields.status.replace(/ /g, '_') + '.png'; + if (issue.fields.status == "open") { + underscoredStatus = "/static/city_issues/img/marker-shadow.png"; + } + var marker = L.marker( + [issue.fields.location_lat, issue.fields.location_lon], + {icon: current.iconCreate(issue.fields.category, issue.fields.status), + title: issue.fields.title, + customId: issue.pk, + customStatus: underscoredStatus, + }); + + marker.on("click", function(event){ + current.showIssueDetails(this.options.customId, event); + if (!current.currentMarker) { + current.currentMarker = this; + } + + if (this !== current.currentMarker) { + if (current.currentMarker._shadow !== null) { + current.currentMarker._shadow.src = current.currentMarker.options.customStatus; + } + current.currentMarker = this; + } + + this._shadow.src ='/static/city_issues/img/icon_active.png'; + current.issueDescriptionBox.loadCurrentMarkerObject(this); + }); + + current.markers.addLayer(marker); + current.map.addLayer(current.markers); + }); + + + }; + + IssueMap.prototype.filterHandler = function(event) { + if (event) { + event.preventDefault(); + } + var dateFromValue = document.querySelector("#id_date_from").value; + var dateToValue = document.querySelector("#id_date_to").value; + var statusArr = []; + var filterToSetChecked = localStorage.getItem('updateStatus'); + + current.statusRawArr.forEach(function(element) { + var elementName = (element) ? element.name.slice(5).replace(/_/g, " ") : undefined; + + if (element && filterToSetChecked && elementName == filterToSetChecked) { + localStorage.removeItem('updateStatus'); + element.checked = true; + } + + if (element && element.checked ) { + statusArr.push(elementName); + } + }); + + var categoryValue = document.querySelector("#id_category").value; + var searchValue = document.querySelector("#id_search").value; + + document.querySelector("#message_box").style.display = "none"; + current.getMarkers( + "getissuesall/?" + + "filter=" + "True" + "&" + + "date_from=" + dateFromValue + "&" + + "date_to=" + dateToValue + "&" + + "status_arr=" + statusArr + "&" + + "category=" + categoryValue + "&" + + "search=" + searchValue + ); + + }; + + + IssueMap.prototype.filterCloseHandler = function(event) { + document.querySelector("#issue_form-container").style.display = "none"; + current.filterFormShowBtnId.style.display = "block"; + }; + + IssueMap.prototype.filterShowHandler = function(event) { + document.querySelector("#issue_form-container").style.display = "block"; + current.filterFormShowBtnId.style.display = "none"; + }; + + IssueMap.prototype.addHandler = function() { + current.filterFormBtn.addEventListener('click', current.filterHandler); + current.filterFormCloseBtnId.addEventListener('click', current.filterCloseHandler); + current.filterFormShowBtnId.addEventListener('click', current.filterShowHandler); + }; + + IssueMap.prototype.getMarkers = function(serverURL) { + var xml = new XMLHttpRequest(); + xml.open("GET", serverURL, true); + xml.send(); + xml.onload = function(){ + if (xml.responseText == "\"[]\"") { + if (current.markers) { + current.map.removeLayer(current.markers); + } + document.querySelector("#message_box").style.display = "block"; + document.querySelector("#message_box li").innerHTML = "No data for that filter choice."; + return; + } + document.querySelector("#message_box").style.display = "none"; + var response = JSON.parse(xml.responseText).slice(1,-1).replace(/}, {/g,'}}, {{').split('}, {'); + current.insertAllMarkers(response); + }; + }; + +} + + +function IssueDescription(mapObject, mapId, issueContainerId, issueCloseId) { + var current = this; + this.mapObject = mapObject; + this.mapId = mapId; + this.issueContainerId = issueContainerId; + this.issueCloseId = issueCloseId; + this.issue_box = document.getElementById(issueContainerId); + this.actionButtons = { + 'open' : document.querySelector(".issue_action[data-action=open]"), + 'edit' : document.querySelector("#issue_action-edit"), + 'pending close' : document.querySelector(".issue_action[data-action='pending close']"), + 'closed' : document.querySelector(".issue_action[data-action=closed]"), + 'deleted' : document.querySelector(".issue_action[data-action=deleted]"), + }; + this.commentStatusButtons = ((document.querySelector("#issue_comments-form")) ? + { + 'public' : document.querySelector("#id_status_0").parentElement, + 'private' : document.querySelector("#id_status_1").parentElement, + 'internal' : document.querySelector("#id_status_2").parentElement, + } : undefined); + + IssueDescription.prototype.loadCurrentMarkerObject = function(obj) { + current.markerObject = obj; + }; + + IssueDescription.prototype.removeActionsElements = function(dict) { + if (dict) { + for (var key in dict) { + dict[key].style.display = "none"; + } + } + + }; + + IssueDescription.prototype.listenActionsButtons = function(event){ + if (event.target.dataset.target == "#action_modal") { + current.issueId = event.target.getAttribute("data-id"); + current.issueAction = event.target.getAttribute("data-action"); + } + }; + + IssueDescription.prototype.paintCommnetsInput = function() { + if(document.querySelector('#id_status')) { + var checkedElement = document.querySelector('#id_status input:checked'); + var commentsInput = document.querySelector('.issue_comments-form textarea'); + if (commentsInput.classList.length > 0) { + commentsInput.classList.forEach(function(className) { + if (className.indexOf('textarea--') !== -1) { + commentsInput.classList.remove(className); + } + }); + } + commentsInput.classList.add("textarea--" + checkedElement.value); + } + }; + + IssueDescription.prototype.sendActionData = function(event) { + event.preventDefault(); + $('#action_modal').modal('hide'); + localStorage.setItem('updateStatus', current.issueAction); + var csrf = document.querySelector("#form_action input[name=csrfmiddlewaretoken]").value; + var formData = new FormData(); + formData.append("action", current.issueAction); + formData.append("issue_id", current.issueId); + formData.append("csrfmiddlewaretoken", csrf); + current.issue_box.style.display = "none"; + current.sendAction(formData, current.issueId); + }; + + IssueDescription.prototype.closeHandler = function(event) { + if (event.target.id == current.issueCloseId || (event.target.id == "mapid" && current.issue_box.style.display == "block")) { + current.issue_box.style.display = "none"; + + + if (current.markerObject._shadow) { + current.markerObject._shadow.src = current.markerObject.options.customStatus; + } + } + }; + + + IssueDescription.prototype.insertComments = function(jsonData, key) { + var commentsList = document.querySelector("#issue_comments"); + commentsList.innerHTML = ""; + for (var i = 0; i < jsonData.length; i++) { + var item = key ? jsonData[i] : JSON.parse(jsonData[i]); + var commentBox = document.createElement('li'); + var commentHeader = document.createElement('div'); + var commentText = document.createElement('p'); + var commentAuthor = document.createElement('span'); + + commentBox.classList.add("issue_comment-box"); + + commentHeader.classList.add("issue_comment-header"); + commentHeader.appendChild(document.createTextNode(item.date_public.slice(0,16) + " ")); + + commentAuthor.appendChild(document.createTextNode(item.user__alias)); + commentAuthor.classList.add("issue_comment-author"); + + commentText.appendChild(document.createTextNode(item.comment)); + commentText.classList.add("issue_comment-text"); + commentText.classList.add("issue_comment-text--" + item.status); + + commentHeader.appendChild(commentAuthor); + commentBox.appendChild(commentHeader); + commentBox.appendChild(commentText); + commentsList.appendChild(commentBox); + } + }; + + IssueDescription.prototype.sendComment = function(data,issue_id) { + var xml = new XMLHttpRequest(); + xml.open("POST", "/postcomment/" + issue_id + "/"); + + xml.onload = function() { + if (xml.status === 200) { + var response = JSON.parse(xml.responseText).slice(1,-1).replace(/}, {/g,'}}, {{').split('}, {'); + current.insertComments(response); + } + }; + + xml.send(data); + }; + + IssueDescription.prototype.sendAction = function(data,issue_id) { + var xml = new XMLHttpRequest(); + xml.open("POST", "/issueaction/" + issue_id + "/"); + xml.onload = function() { + var response = JSON.parse(xml.responseText); + if (response.result == "success") { + current.mapObject.filterHandler(); + } + }; + xml.send(data); + }; + + + + IssueDescription.prototype.commentsHandler = function(event) { + event.preventDefault(); + var comment = document.querySelector("#id_comment").value; + var commentStatus = document.querySelector("#id_status input:checked").value; + var csrf = document.querySelector("#issue_comments-form input[name=csrfmiddlewaretoken]").value; + var issue_id = event.target.getAttribute("data-id"); + if (comment.length > 0) { + document.querySelector("#id_comment").value = ""; + var formData = new FormData(); + formData.append("comment", comment); + formData.append("status", commentStatus); + formData.append("csrfmiddlewaretoken", csrf); + current.sendComment(formData, issue_id); + } + }; + + IssueDescription.prototype.addHandler = function() { + document.addEventListener('click', current.closeHandler); + if ( document.querySelector("#issue_comments-form-btn")) { + document.querySelector("#issue_comments-form-btn").addEventListener('click', current.commentsHandler); + } + document.querySelector("#issue_buttons-box").addEventListener("click", current.listenActionsButtons); + document.querySelector("#issue_action-send").addEventListener("click", current.sendActionData); + if ( document.querySelector("#id_status")) { + document.querySelector("#id_status").addEventListener('click', current.paintCommnetsInput); + } + }; + + + IssueDescription.prototype.insertIssueData = function(jsonData, issue_id) { + current.issue_box.style.display = 'block'; + current.removeActionsElements(current.actionButtons); + jsonData.dict_of_actions.list_of_actions.forEach(function(button) { + current.actionButtons[button].style.display = "inline-block"; + current.actionButtons[button].style.verticalAlign = "top"; + current.actionButtons[button].setAttribute("data-id", issue_id); + }); + + current.removeActionsElements(current.commentStatusButtons); + commentsButtonsNumber = jsonData.dict_of_actions.list_of_comments_statuses.length; + if (current.commentStatusButtons && commentsButtonsNumber > 1) { + jsonData.dict_of_actions.list_of_comments_statuses.forEach(function(button) { + current.commentStatusButtons[button].style.display = "inline-block"; + current.commentStatusButtons[button].style.verticalAlign = "top"; + }); + } else if (current.commentStatusButtons && commentsButtonsNumber === 1) { + button = jsonData.dict_of_actions.list_of_comments_statuses[0]; + current.commentStatusButtons[button].type = 'hidden'; + current.commentStatusButtons[button].firstElementChild.checked = true; + } + current.paintCommnetsInput(); + + if (document.querySelector("#issue_comments-form-btn")) { + document.querySelector("#issue_comments-form-btn").setAttribute("data-id", issue_id); + } + current.insertComments(jsonData.comments, true); + + document.querySelector("#issue_title").innerHTML = jsonData.title; + document.querySelector(".issue_description").innerHTML = jsonData.description; + document.querySelector("#issue_category").innerHTML = jsonData.category__category; + document.querySelector("#issue_status").innerHTML = ''; + document.querySelector("#issue_status").appendChild(document.createTextNode(jsonData.status.charAt(0).toUpperCase() + jsonData.status.slice(1))); + var imgBox = document.querySelector(".issue_img-box"); + imgBox.innerHTML = ""; + if (jsonData.images_urls.length > 0) { + insertTemplate("#issue_img-box", "#bootstrap_carousel"); + bootstrapCarousel(jsonData.images_urls); + } else { + var img = document.createElement('img'); + img.src = "/static/city_issues/img/no-image.png"; + img.classList.add("issue_img"); + imgBox.appendChild(img); + } + var editBtn = document.getElementById("issue_action-edit"); + var dataUrl = editBtn.getAttribute("data-url").slice(0,-1); + editBtn.setAttribute("href", dataUrl + issue_id); + }; + + + IssueDescription.prototype.getIssueById = function(issue_id) { + var xml = new XMLHttpRequest(); + xml.open("GET", "getissuebyid/" + issue_id, true); + xml.send(); + xml.onload = function(){ + var response = JSON.parse(JSON.parse(xml.responseText).slice(1,-1)); + current.insertIssueData(response, issue_id); + }; + }; + +} + +function bootstrapCarousel(images_urls) { + $(document).ready(function(){ + for(var i=0 ; i< images_urls.length ; i++) { + var fullImgUrl = images_urls[i]; + if (fullImgUrl === null) { + fullImgUrl = "/static/city_issues/img/no-image.png"; + } + + $('
    ').appendTo('.carousel-inner'); + $('
  • ').appendTo('.carousel-indicators'); + } + $('.item').first().addClass('active'); + $('.carousel-indicators > li').first().addClass('active'); + $('#carousel-example-generic').carousel(); +}); +} + + +function insertTemplate(parentId, templateId) { + if ('content' in document.createElement('template')) { + var template = document.querySelector(templateId); + var parent = document.querySelector(parentId); + var clone = document.importNode(template.content, true); + parent.appendChild(clone); + } +} + +function placeFilter() { + var leafletControls = document.querySelector(".leaflet-top.leaflet-left"); + var filterForm = document.querySelector("#issue_form-box"); + + leafletControlsCoordinatse = leafletControls.getBoundingClientRect(); + filterForm.style.top = (leafletControlsCoordinatse.bottom + 5) + "px"; + filterForm.style.left = (leafletControlsCoordinatse.left + 5) + "px"; +} + +$(document).ready(function() { + issueMap = new IssueMap("mapid"); + issueMap.setFilterFromBtn("#issue_filter-form-btn"); + issueMap.setFilterFromCloseBtn("#issue_filter-form-close-btn"); + issueMap.setFilterFromShowBtn("#issue_filter-form-show-btn"); + issueMap.setViewPoint(50.621945, 26.249314, 16); + issueMap.addMapLayer( + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 19, + '© OpenStreetMap'); + issueMap.filterHandler(); + issueMap.addHandler(); + + insertTemplate("#message_box", "#message_list"); + placeFilter(); +}); +})(); + diff --git a/client/apps/city_issues/static/css/chat.css b/client/apps/city_issues/static/css/chat.css new file mode 100644 index 0000000..c2c9b0b --- /dev/null +++ b/client/apps/city_issues/static/css/chat.css @@ -0,0 +1,469 @@ + +body{ + background:#eee; +} +.box { + position: relative; + border-radius: 3px; + background: #ffffff; + border-top: 3px solid #d2d6de; + margin-bottom: 20px; + width: 100%; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); +} +.box.box-primary { + border-top-color: #3c8dbc; +} +.box.box-info { + border-top-color: #00c0ef; +} +.box.box-danger { + border-top-color: #dd4b39; +} +.box.box-warning { + border-top-color: #f39c12; +} +.box.box-success { + border-top-color: #00a65a; +} +.box.box-default { + border-top-color: #d2d6de; +} +.box.collapsed-box .box-body, .box.collapsed-box .box-footer { + display: none; +} +.box .nav-stacked>li { + border-bottom: 1px solid #f4f4f4; + margin: 0; +} +.box .nav-stacked>li:last-of-type { + border-bottom: none; +} +.box.height-control .box-body { + max-height: 300px; + overflow: auto; +} +.box .border-right { + border-right: 1px solid #f4f4f4; +} +.box .border-left { + border-left: 1px solid #f4f4f4; +} +.box.box-solid { + border-top: 0; +} +.box.box-solid>.box-header .btn.btn-default { + background: transparent; +} +.box.box-solid>.box-header .btn:hover, .box.box-solid>.box-header a:hover { + background: rgba(0, 0, 0, 0.1); +} +.box.box-solid.box-default { + border: 1px solid #d2d6de; +} +.box.box-solid.box-default>.box-header { + color: #444; + background: #d2d6de; + background-color: #d2d6de; +} +.box.box-solid.box-default>.box-header a, .box.box-solid.box-default>.box-header .btn { + color: #444; +} +.box.box-solid.box-primary { + border: 1px solid #3c8dbc; +} +.box.box-solid.box-primary>.box-header { + color: #fff; + background: #3c8dbc; + background-color: #3c8dbc; +} +.box.box-solid.box-primary>.box-header a, .box.box-solid.box-primary>.box-header .btn { + color: #fff; +} +.box.box-solid.box-info { + border: 1px solid #00c0ef; +} +.box.box-solid.box-info>.box-header { + color: #fff; + background: #00c0ef; + background-color: #00c0ef; +} +.box.box-solid.box-info>.box-header a, .box.box-solid.box-info>.box-header .btn { + color: #fff; +} +.box.box-solid.box-danger { + border: 1px solid #dd4b39; +} +.box.box-solid.box-danger>.box-header { + color: #fff; + background: #dd4b39; + background-color: #dd4b39; +} +.box.box-solid.box-danger>.box-header a, .box.box-solid.box-danger>.box-header .btn { + color: #fff; +} +.box.box-solid.box-warning { + border: 1px solid #f39c12; +} +.box.box-solid.box-warning>.box-header { + color: #fff; + background: #f39c12; + background-color: #f39c12; +} +.box.box-solid.box-warning>.box-header a, .box.box-solid.box-warning>.box-header .btn { + color: #fff; +} +.box.box-solid.box-success { + border: 1px solid #00a65a; +} +.box.box-solid.box-success>.box-header { + color: #fff; + background: #00a65a; + background-color: #00a65a; +} +.box.box-solid.box-success>.box-header a, .box.box-solid.box-success>.box-header .btn { + color: #fff; +} +.box.box-solid>.box-header>.box-tools .btn { + border: 0; + box-shadow: none; +} +.box.box-solid[class*='bg']>.box-header { + color: #fff; +} +.box .box-group>.box { + margin-bottom: 5px; +} +.box .knob-label { + text-align: center; + color: #333; + font-weight: 100; + font-size: 12px; + margin-bottom: 0.3em; +} +.box>.overlay, .overlay-wrapper>.overlay, .box>.loading-img, .overlay-wrapper>.loading-img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%} +.box .overlay, .overlay-wrapper .overlay { + z-index: 50; + background: rgba(255, 255, 255, 0.7); + border-radius: 3px; +} +.box .overlay>.fa, .overlay-wrapper .overlay>.fa { + position: absolute; + top: 50%; + left: 50%; + margin-left: -15px; + margin-top: -15px; + color: #000; + font-size: 30px; +} +.box .overlay.dark, .overlay-wrapper .overlay.dark { + background: rgba(0, 0, 0, 0.5); +} +.box-header:before, .box-body:before, .box-footer:before, .box-header:after, .box-body:after, .box-footer:after { + content: " "; + display: table; +} +.box-header:after, .box-body:after, .box-footer:after { + clear: both; +} +.box-header { + color: #444; + display: block; + padding: 10px; + position: relative; +} +.box-header.with-border { + border-bottom: 1px solid #f4f4f4; +} +.collapsed-box .box-header.with-border { + border-bottom: none; +} +.box-header>.fa, .box-header>.glyphicon, .box-header>.ion, .box-header .box-title { + display: inline-block; + font-size: 18px; + margin: 0; + line-height: 1; +} +.box-header>.fa, .box-header>.glyphicon, .box-header>.ion { + margin-right: 5px; +} +.box-header>.box-tools { + position: absolute; + right: 10px; + top: 5px; +} +.box-header>.box-tools [data-toggle="tooltip"] { + position: relative; +} +.box-header>.box-tools.pull-right .dropdown-menu { + right: 0; + left: auto; +} +.btn-box-tool { + padding: 5px; + font-size: 12px; + background: transparent; + color: #97a0b3; +} +.open .btn-box-tool, .btn-box-tool:hover { + color: #606c84; +} +.btn-box-tool.btn:active { + box-shadow: none; +} +.box-body { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; + padding: 10px; +} +.no-header .box-body { + border-top-right-radius: 3px; + border-top-left-radius: 3px; +} +.box-body>.table { + margin-bottom: 0; +} +.box-body .fc { + margin-top: 5px; +} +.box-body .full-width-chart { + margin: -19px; +} +.box-body.no-padding .full-width-chart { + margin: -9px; +} +.box-body .box-pane { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 3px; +} +.box-body .box-pane-right { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 0; +} +.box-footer { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; + border-top: 1px solid #f4f4f4; + padding: 10px; + background-color: #fff; +} +.direct-chat .box-body { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + position: relative; + overflow-x: hidden; + padding: 0; +} +.direct-chat.chat-pane-open .direct-chat-contacts { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); +} +.direct-chat-messages { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); + padding: 10px; + height: 250px; + overflow: auto; +} +.direct-chat-msg, .direct-chat-text { + display: block; +} +.direct-chat-msg { + margin-bottom: 10px; +} +.direct-chat-msg:before, .direct-chat-msg:after { + content: " "; + display: table; +} +.direct-chat-msg:after { + clear: both; +} +.direct-chat-messages, .direct-chat-contacts { + -webkit-transition: -webkit-transform .5s ease-in-out; + -moz-transition: -moz-transform .5s ease-in-out; + -o-transition: -o-transform .5s ease-in-out; + transition: transform .5s ease-in-out; +} +.direct-chat-text { + border-radius: 5px; + position: relative; + padding: 5px 10px; + background: #d2d6de; + border: 1px solid #d2d6de; + margin: 5px 0 0 50px; + color: #444; +} +.direct-chat-text:after, .direct-chat-text:before { + position: absolute; + right: 100%; + top: 15px; + border: solid transparent; + border-right-color: #d2d6de; + content: ' '; + height: 0; + width: 0; + pointer-events: none; +} +.direct-chat-text:after { + border-width: 5px; + margin-top: -5px; +} +.direct-chat-text:before { + border-width: 6px; + margin-top: -6px; +} +.right .direct-chat-text { + margin-right: 50px; + margin-left: 0; +} +.right .direct-chat-text:after, .right .direct-chat-text:before { + right: auto; + left: 100%; + border-right-color: transparent; + border-left-color: #d2d6de; +} +.direct-chat-img { + border-radius: 50%; + float: left; + width: 40px; + height: 40px; +} +.right .direct-chat-img { + float: right; +} +.direct-chat-info { + display: block; + margin-bottom: 2px; + font-size: 12px; +} +.direct-chat-name { + font-weight: 600; +} +.direct-chat-timestamp { + color: #999; +} +.direct-chat-contacts-open .direct-chat-contacts { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); +} +.direct-chat-contacts { + -webkit-transform: translate(101%, 0); + -ms-transform: translate(101%, 0); + -o-transform: translate(101%, 0); + transform: translate(101%, 0); + position: absolute; + top: 0; + bottom: 0; + height: 250px; + width: 100%; + background: #222d32; + color: #fff; + overflow: auto; +} +.contacts-list>li { + border-bottom: 1px solid rgba(0, 0, 0, 0.2); + padding: 10px; + margin: 0; +} +.contacts-list>li:before, .contacts-list>li:after { + content: " "; + display: table; +} +.contacts-list>li:after { + clear: both; +} +.contacts-list>li:last-of-type { + border-bottom: none; +} +.contacts-list-img { + border-radius: 50%; + width: 40px; + float: left; +} +.contacts-list-info { + margin-left: 45px; + color: #fff; +} +.contacts-list-name, .contacts-list-status { + display: block; +} +.contacts-list-name { + font-weight: 600; +} +.contacts-list-status { + font-size: 12px; +} +.contacts-list-date { + color: #aaa; + font-weight: normal; +} +.contacts-list-msg { + color: #999; +} +.direct-chat-danger .right>.direct-chat-text { + background: #dd4b39; + border-color: #dd4b39; + color: #fff; +} +.direct-chat-danger .right>.direct-chat-text:after, .direct-chat-danger .right>.direct-chat-text:before { + border-left-color: #dd4b39; +} +.direct-chat-primary .right>.direct-chat-text { + background: #3c8dbc; + border-color: #3c8dbc; + color: #fff; +} +.direct-chat-primary .right>.direct-chat-text:after, .direct-chat-primary .right>.direct-chat-text:before { + border-left-color: #3c8dbc; +} +.direct-chat-warning .right>.direct-chat-text { + background: #f39c12; + border-color: #f39c12; + color: #fff; +} +.direct-chat-warning .right>.direct-chat-text:after, .direct-chat-warning .right>.direct-chat-text:before { + border-left-color: #f39c12; +} +.direct-chat-info .right>.direct-chat-text { + background: #00c0ef; + border-color: #00c0ef; + color: #fff; +} +.direct-chat-info .right>.direct-chat-text:after, .direct-chat-info .right>.direct-chat-text:before { + border-left-color: #00c0ef; +} +.direct-chat-success .right>.direct-chat-text { + background: #00a65a; + border-color: #00a65a; + color: #fff; +} +.direct-chat-success .right>.direct-chat-text:after, .direct-chat-success .right>.direct-chat-text:before { + border-left-color: #00a65a; +} + +input.error{ + border: 1px solid #ff1100; +} + +span.error{ + color: #ff1100; +} \ No newline at end of file diff --git a/client/apps/city_issues/static/css/general.css b/client/apps/city_issues/static/css/general.css new file mode 100644 index 0000000..0237b3b --- /dev/null +++ b/client/apps/city_issues/static/css/general.css @@ -0,0 +1,37 @@ + +li.success, li.error { + float: right; + font-size: 18px; + font-weight: bold; +} + +li.success{ + color: green; +} + +li.error{ + color: red; +} + +ul.messages{ + list-style-type: none; + position: absolute; + top: 75px; + z-index: 1000; + right: 90px; + background: white; +} + +a.del-msg{ + float: right; +} + +.wrapper{ + margin: 0 100px; + min-width: 1100px; +} + +.width{ + width: 100%; + margin: 20px 0; +} \ No newline at end of file diff --git a/client/apps/city_issues/static/css/issues.css b/client/apps/city_issues/static/css/issues.css new file mode 100644 index 0000000..a195cad --- /dev/null +++ b/client/apps/city_issues/static/css/issues.css @@ -0,0 +1,115 @@ +#map{ + height: 600px; + width: 100%; +} + +.width h1{ + text-align: center; +} + +.file-upload { + position: relative; + height: 32px; + width: 100%; + cursor: pointer; + font-size: 14px; + font-family: sans-serif; + line-height: 1.42857143; + color: #555; +} + +.file-upload:after { + content: "Обзор"; + position: absolute; + display: block; + top: 0; + right: 0; + z-index: 5; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 0 4px 4px 0; + padding: 6px 12px; + color: #333; +} +.file-upload:hover:after { + color: #333; + background-color: #e6e6e6; + border-color: #adadad; +} + +.file-upload:active:after { + outline: 0; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} + +.file-upload:before { + content: attr(data-text); + position: absolute; + top: 0; + left: 0; + z-index: 3; + display: block; + width: calc(100% - 40px); + padding: 6px 12px; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; +} + +.file-upload input { + position: absolute; + display: block; + top: 0; + height: 100%; + z-index: 99; + width: 100%; + opacity: 0; +} + +.form-group.required .control-label:after { + content:"*"; + color:red; +} + +.images { + margin-top: 15px; +} + +.imgs { + max-width: 300px; + max-height: 350px; + display: flex; + margin-bottom: 10px; +} + +.del-img { + color: red; + font-size: 25px; + float: right; + display: block; + width: 250px; + margin-bottom: -19px; + position: relative; + bottom: 10px; + cursor: pointer; +} + +.del-img:hover{ + text-decoration: none; + color: red; +} + +.modal-body h3{ + text-align: center; +} + +.status { + border-radius: 50%; + cursor: pointer; +} + +.card .container-fliud .preview.col-md-6 img.imgs +{ + max-width: 360px; +} \ No newline at end of file diff --git a/client/apps/city_issues/static/css/user_page.css b/client/apps/city_issues/static/css/user_page.css new file mode 100644 index 0000000..3dbf932 --- /dev/null +++ b/client/apps/city_issues/static/css/user_page.css @@ -0,0 +1,36 @@ +.wrapper{ + margin: 0 100px; + min-width: 1100px; +} + +.width{ + width: 100%; + margin: 20px 0; +} + +.panel-heading, .panel-footer{ + height: 50px; +} + +.user_message{ + position: relative; + bottom: 20px; +} + +.dataTable > thead > tr > th[class*="sort"]:after{ + content: ""; +} + +ul.errorlist{ + position: relative; + right: 38px; +} + +ul.errorlist li{ + color: red; + list-style: none; +} + +..modal-title{ + display: inline-block; +} \ No newline at end of file diff --git a/client/apps/city_issues/static/images/avatar.png b/client/apps/city_issues/static/images/avatar.png new file mode 100644 index 0000000..d1b05bf Binary files /dev/null and b/client/apps/city_issues/static/images/avatar.png differ diff --git a/client/apps/city_issues/static/js/general.js b/client/apps/city_issues/static/js/general.js new file mode 100644 index 0000000..6f4bc4a --- /dev/null +++ b/client/apps/city_issues/static/js/general.js @@ -0,0 +1,10 @@ +setTimeout(function () +{ + $('.messages').fadeOut('slow'); +}, 10000); + +$('.del-msg').on('click', function (e) +{ + e.preventDefault(); + $('.del-msg').parent().hide(); +}); \ No newline at end of file diff --git a/client/apps/city_issues/static/js/internal-comments.js b/client/apps/city_issues/static/js/internal-comments.js new file mode 100644 index 0000000..90ef2ce --- /dev/null +++ b/client/apps/city_issues/static/js/internal-comments.js @@ -0,0 +1,89 @@ +function InternalComments(userId) +{ + this.currentUserId = userId; + this.form = $('#comment-form'); + this.getUrl = ''; + this.storeUrl = ''; + this.commentInput = $('input[name=comment]'); + + $('button.comments').on('click', $.proxy(this.onCommentsClick, this)); + this.form.submit($.proxy(this.sendComment, this)) +} + +InternalComments.prototype.onCommentsClick = function (e) +{ + this.getUrl = $(e.target).attr('data-url'); + this.storeUrl = $(e.target).attr('data-store-url'); + + this.form.attr('action', this.storeUrl); + + this.getComments(); + this.clearErrors(); + +}; + +InternalComments.prototype.getComments = function () +{ + var $this = this; + + $.get($this.getUrl, function (answer) + { + $('#internal-comments').show(); + var comments = $(); + + answer['comments'].forEach(function (item) + { + var isMessageRight = $this.currentUserId == (item.user_id) ? 'right': ''; + var date = new Date(item.date_public); + var formattedDate = date.getHours() + ":" + date.getMinutes() + '/' + date.getDay() + '/' + date.getMonth() + '/' + date.getFullYear(); + comments = comments.add( + "
    \n" + + "
    \n" + + "" + item.user__alias + "\n" + + "" + formattedDate + "\n" + + "
    \n" + + "\"Message\n" + + "
    " + item.comment + "
    \n" + + "
    "); + }); + + + $(".direct-chat-messages").html(comments); + $this.clearErrors(); + }) + .fail(function () + { + $(".direct-chat-messages").html($('h2').text('Error loading comments')); + }); +}; + +InternalComments.prototype.sendComment = function (e) +{ + e.preventDefault(); + var $this = this; + + $.post(this.form.attr('action'), this.form.serialize(), function(answer) + { + $this.getComments(); + var messageContainer = $('.direct-chat-messages'); + messageContainer.animate({ scrollTop: messageContainer[0].scrollHeight}, 1000); + + $this.commentInput.val(''); + } + ) + .fail(function (answer) + { + var errorText = JSON.parse(answer.responseJSON).comment[0].message; + + $this.commentInput.addClass('error'); + $('span.error').text(errorText); + + }); +}; + + +InternalComments.prototype.clearErrors = function() +{ + this.commentInput.removeClass('error'); + $('span.error').text(''); +}; diff --git a/client/apps/city_issues/static/js/issues.js b/client/apps/city_issues/static/js/issues.js new file mode 100644 index 0000000..55a75a5 --- /dev/null +++ b/client/apps/city_issues/static/js/issues.js @@ -0,0 +1,59 @@ +function IssueMap() +{ + this.map = L.map('map'); + if (localStorage.getItem('lat') && localStorage.getItem('lng')) { + this.map.setView([localStorage.getItem('lat'), localStorage.getItem('lng')], 15); + } + else { + this.map.locate({setView: true, maxZoom: 50}); + } + + + this.layer = L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png').addTo(this.map); + + this.marker = ''; + + this.map.on('click',($.proxy(this.getLocation, this))); + this.submitBtn = $("#form_submit-btn"); + this.submitBtn.on('click', $.proxy(this.saveLocation, this)); +} + +IssueMap.prototype.getLocation = function (e) +{ + if(this.map.hasLayer(this.marker)) + this.map.removeLayer(this.marker); + + var location = e.latlng; + this.marker = L.marker(location, { draggable: true}).addTo(this.map); + this.marker.off('dragend'); + this.marker.on('dragend', ($.proxy(this.getDragLocation, this))); + + $("input[name=location_lat]").val(location.lat); + $("input[name=location_lon]").val(location.lng); +}; + +IssueMap.prototype.getDragLocation = function (e) { + if (this.map.hasLayer(this.marker)) + this.map.removeLayer(this.marker); + this.marker = e.target; + var location = this.marker.getLatLng(); + this.marker.setLatLng(location, {draggable: true}).addTo(this.map); + + $("input[name=location_lat]").val(location.lat); + $("input[name=location_lon]").val(location.lng); +}; + + +IssueMap.prototype.saveLocation = function (e) +{ + e.preventDefault(); + localStorage.setItem('lat', $("input[name=location_lat]").val()); + localStorage.setItem('lng', $("input[name=location_lon]").val()); + $("#issue_create-form").submit(); + +}; + +$(function () +{ + new IssueMap(); +}); diff --git a/client/apps/city_issues/static/js/search.js b/client/apps/city_issues/static/js/search.js new file mode 100644 index 0000000..4484bea --- /dev/null +++ b/client/apps/city_issues/static/js/search.js @@ -0,0 +1,4 @@ +$( document ).ready(function() { + var search = $('#form').data('search'); + $('input[name=search]').val(search); +}); \ No newline at end of file diff --git a/client/apps/city_issues/static/mod_static/css/image.css b/client/apps/city_issues/static/mod_static/css/image.css new file mode 100644 index 0000000..bd0d97c --- /dev/null +++ b/client/apps/city_issues/static/mod_static/css/image.css @@ -0,0 +1,10 @@ +img.border { + border: 1px solid #ddd; + border-radius: 4px; + padding: 5px; + width: 150px; +} + +img.box:hover { + box-shadow: 0 0 2px 1px rgba(0, 140, 186, 0.5); +} \ No newline at end of file diff --git a/client/apps/city_issues/static/mod_static/mod_modal.js b/client/apps/city_issues/static/mod_static/mod_modal.js new file mode 100644 index 0000000..3f4f441 --- /dev/null +++ b/client/apps/city_issues/static/mod_static/mod_modal.js @@ -0,0 +1,20 @@ +$( document ).ready(function() { + $('#deletion').on('show.bs.modal', function(e) { + var issueId = $(e.relatedTarget).data('issue-id'); + var name = $(e.relatedTarget).data('name'); + var funcName = $(e.relatedTarget).data('func-name'); + var elemName = $(e.relatedTarget).data('elem-name'); + if (elemName === 'delete'){ + buttonText ='Delete'; + bodyText = "Please click "+buttonText+" button if you want to delete data."; + } + else { + buttonText ='Restore'; + bodyText = "Please click "+buttonText+" button if you want to restore data."; + } + $(".modal-title").text("Confirm operation \"" + name+"\""); + $(".button-confirm").text(buttonText); + $(".modal-body").text(bodyText); + $("#delete-issue").attr('action', '/'+funcName+'/'+ issueId +'/'+ elemName +'/'); + }); +}); diff --git a/client/apps/city_issues/templates/base.html b/client/apps/city_issues/templates/base.html new file mode 100644 index 0000000..82c753f --- /dev/null +++ b/client/apps/city_issues/templates/base.html @@ -0,0 +1,84 @@ +{% load i18n %} +{% load static from staticfiles %} + + + + + + + + City Issues - + {% block meta_title %}{% endblock meta_title %} + + {% block extra_css %} + + {% endblock extra_css %} + + + + + + + + +{% block content %} +{% endblock content %} + + + + +{% block extra_js %}{% endblock extra_js %} + + + diff --git a/client/apps/city_issues/templates/edit_issue.html b/client/apps/city_issues/templates/edit_issue.html new file mode 100644 index 0000000..0bd9cec --- /dev/null +++ b/client/apps/city_issues/templates/edit_issue.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% block meta_title %}Edit issue page{% endblock meta_title %} + +{% block extra_css %} + {% load static %} + + + +{% endblock extra_css %} + +{% block content %} + +
    +
    +
    + {% if messages %} +
      + {% for message in messages %} + + {{ message }} + × + + {% endfor %} +
    + {% endif %} +
    +
    +
    + +
    +
    +
    +
    + {% csrf_token %} + {{ form.as_p }} + +
    +
    + Return to + map + +
    + {% for image in issues.get_attachments %} + × + {{ image.image_url }} + {% endfor %} +
    +
    +
    +
    +
    +
    +
    + + + +{% endblock content %} + +{% block extra_js %} + + + {% load static %} + + + +{% endblock extra_js %} \ No newline at end of file diff --git a/client/apps/city_issues/templates/home_page.html b/client/apps/city_issues/templates/home_page.html new file mode 100644 index 0000000..376d4db --- /dev/null +++ b/client/apps/city_issues/templates/home_page.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static from staticfiles %} + +{% block meta_title %}Home page{% endblock meta_title %} + +{% block content %} + +{% endblock content %} \ No newline at end of file diff --git a/client/apps/city_issues/templates/issue_detailed.html b/client/apps/city_issues/templates/issue_detailed.html new file mode 100644 index 0000000..85816da --- /dev/null +++ b/client/apps/city_issues/templates/issue_detailed.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static from staticfiles %} + +{% block meta_title %}Detailed Issue{% endblock meta_title %} + +{% block extra_css %} + {% load static %} + + + +{% endblock extra_css %} + +{% block content %} + +
    +
    +
    +
    +
    + {% for image in object.get_attachments %} + {% if image.image_url %} + {{ image.image_url }} + {% else %} + photo + {% endif %} + {% endfor %} +
    +
    +
    {{ object.status }}
    +

    {{ object.title }}

    +

    Created: {{ object.user }}

    +

    Category: {{ object.category }}

    +
    Open date: {{ object.open_date|date:"d/m/y" }}
    +
    Close date: {{ object.close_date|date:"d/m/y" }}
    +
    Latitude: {{ object.location_lat }}
    +
    Longitude: {{ object.location_lon }}
    +
    Description:
    +

    {{ object.description }}

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Comments:

    + + {% for com in object.comments_set.all %} + {% if user.pk == com.user_id or user.role.id == 1 or user.role.id == 2 and com.status != 'internal'%} + + + + {% else %} + {% if com.status == 'public' %} + + {% endif %} + {% if user.is_authenticated %} + {% if com.status == 'internal' %} + + {% endif %} + {% endif %} + {% endif %} + + {% endfor %} +
    +
    {{ com.user.role.role|title }}: {{ com.user.name }} {{ com.date_public }} {{ com.status }}
    +

    {{ com.comment|linebreaksbr }}

    +
    + {% if com.status != 'deleted' %} + + {% elif com.status == 'deleted' %} + + {% endif %} + +
    {{ com.user.role.role|title }}: {{ com.user.name }} {{ com.date_public }}
    +

    {{ com.comment|linebreaksbr }}

    +
    +
    {{ com.user.role.role|title }}: {{ com.user.name }} {{ com.date_public }} {{ com.status }}
    +

    {{ com.comment|linebreaksbr }}

    +
    +
    +
    + {% if user.is_authenticated %} +
    +
    +
    +
    + {% csrf_token %} + + +
    + public
    + private
    + internal
    +
    + + + +
    + +
    +
    +
    +
    + {% endif %} +
    + + + + +{% endblock content %} + +{% block extra_js %} + + + {% load static %} + + +{% endblock extra_js %} diff --git a/client/apps/city_issues/templates/issues/issues.html b/client/apps/city_issues/templates/issues/issues.html new file mode 100644 index 0000000..ce14e0b --- /dev/null +++ b/client/apps/city_issues/templates/issues/issues.html @@ -0,0 +1,100 @@ +{% extends "home_page.html" %} + +{% load i18n %} + +{% block meta_title %}Issues page{% endblock meta_title %} + +{% block extra_css %} + {% load static %} + + + +{% endblock extra_css %} + + +{% block content %} +
    +
    +

    Add new issue

    +
    + +
    +
    + {% if messages %} +
      + {% for message in messages %} + + {{ message }} + × + + {% endfor %} +
    + {% endif %} +
    +
    +
    +
    +
    +
    +
    + {% csrf_token %} +
    +
    +

    Click on map and add issue marker

    +
    +
    +
    +
    +
    + + {{ form.location_lat }} +
    +
    +
    +
    + + {{ form.location_lon }} +
    +
    +
    +
    + + {{ form.title }} +
    +
    + + {{ form.description }} +
    +
    + + {{ form.category }} +
    + +
    + + {{ form.files }} +
    + + +
    + +
    +
    +
    +
    +{% endblock content %} + + +{% block extra_js %} + + + {% load static %} + + + +{% endblock extra_js %} diff --git a/client/apps/city_issues/templates/issues_list.html b/client/apps/city_issues/templates/issues_list.html new file mode 100644 index 0000000..9b4404a --- /dev/null +++ b/client/apps/city_issues/templates/issues_list.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static from staticfiles %} +{% load bootstrap3%} +{% load app_filters %} + +{% block meta_title %}All Issues{% endblock meta_title %} + +{% block content %} + + + +{% if is_paginated %} + +{% endif %} + + +{% block extra_js %} +{% load static %} + +{% endblock extra_js %} + +{% endblock content %} diff --git a/client/apps/city_issues/templates/map_page.html b/client/apps/city_issues/templates/map_page.html new file mode 100644 index 0000000..7ba5b7d --- /dev/null +++ b/client/apps/city_issues/templates/map_page.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static from staticfiles %} + +{% block meta_title %}Map page{% endblock meta_title %} +{% block extra_css %} + + + + {% load static %} + + +{% endblock extra_css %} + +{% block content %} + +
    +
    +
    + {% if messages %} +
      + {% for message in messages %} + + {{ message }} + × + + {% endfor %} +
    + {% endif %} +
    +
    +
    + +
    + +
    +

    + {% csrf_token %} + {% for field in form %} + + {% if field.auto_id == "id_category" %} +
    + {% endif %} + +
    + {{field.errors}} + {{field.label_tag}} + {{field}} +
    + + {% if field.auto_id == "id_date_to" %} +

    Status:

    +
    + {% endif %} + + {% endfor %} + + +
    +
    + +
    + +
    +
    +
    +
    +
    +

    +

    +
    +
    +

    +
    +
    + + Edit + + + + +
    +
    + {% if user.is_authenticated %} +
    + {% csrf_token %} + {% for field in comment_form %} + {{field.errors}} + {{field.label_tag}} + {{field}} + {% endfor %} + +
    + {% endif %} + +
      + +
      +
      + + + + + + +{% endblock content %} + +{% block extra_js %} + + +{% load static %} + + + + +{% endblock extra_js %} diff --git a/client/apps/city_issues/templates/mod/mod_comments.html b/client/apps/city_issues/templates/mod/mod_comments.html new file mode 100644 index 0000000..7d04570 --- /dev/null +++ b/client/apps/city_issues/templates/mod/mod_comments.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% load bootstrap3%} + +{% block meta_title %}{{ title }}{% endblock meta_title %} + +{% block extra_css %} + {% load static %} + +{% endblock extra_css %} + +{% block content %} + +
      +
      +
      +

      Add a new comment: {{ issue.title }}

      +
      + {% csrf_token %} + {% bootstrap_form form %} + + + Return to edit issue +
      +
      +
      + {% for com in issue.comments_set.all %} + {{ com.date_public }} + {{ user.name }} +
      +
      +

      {{ com.comment|safe|linebreaks }}

      + {% endfor %} +
      +
      +
      + +{% endblock content %} \ No newline at end of file diff --git a/client/apps/city_issues/templates/mod/mod_edit.html b/client/apps/city_issues/templates/mod/mod_edit.html new file mode 100644 index 0000000..a23f669 --- /dev/null +++ b/client/apps/city_issues/templates/mod/mod_edit.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% load bootstrap3%} + +{% block meta_title %}{{ title }}{% endblock meta_title %} +{% block extra_css %} + {% load static %} + + + + +{% endblock extra_css %} + +{% block content %} + +
      +
      + + +
      +
      +
      +
      +
      + {% if messages %} +
      + {% for message in messages %} + {{ message }}

      + {% endfor %} +
      + + {% endif %} +
      +
      +
      + +
      +
      +
      +
      + {% csrf_token %} + {{ form.as_p }} + + {% if not issue.delete_date %} + + + {% else %} + + + {% endif %} + Comments +
      +
      + Return to + moderator panel +{#
      #} +{# {% for image in issue.get_attachments %}#} +{# ×#} +{# {{ image.image_url }}#} +{# {% endfor %}#} +
      + {% for image in issue.get_attachments %} + × + + {{ image.image_url }} + {% endfor %} +
      +
      +
      +
      +
      +
      +
      +
      +
      + + + + + +{% endblock content %} +{% block extra_js %} + + + {% load static %} + + + + +{% endblock extra_js %} \ No newline at end of file diff --git a/client/apps/city_issues/templates/mod/mod_list.html b/client/apps/city_issues/templates/mod/mod_list.html new file mode 100644 index 0000000..9bf3fc8 --- /dev/null +++ b/client/apps/city_issues/templates/mod/mod_list.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% load bootstrap3%} + +{% block meta_title %}Moderation Panel{% endblock meta_title %} + +{% block content %} + +
      +
      + +
      + + + +{% bootstrap_pagination issues_list url=last_query %} + +
      + + + +{% endblock content %} + +{% block extra_js %} +{% load static %} + +{% endblock extra_js %} \ No newline at end of file diff --git a/client/apps/city_issues/templates/mod/permission.html b/client/apps/city_issues/templates/mod/permission.html new file mode 100644 index 0000000..aabb011 --- /dev/null +++ b/client/apps/city_issues/templates/mod/permission.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% load bootstrap3%} + +{% block meta_title %}{{ title }}{% endblock meta_title %} + +{% block content %} +
      +
      +

      {{ title }}

      +
      +
      + +{% endblock content %} \ No newline at end of file diff --git a/client/apps/city_issues/templates/registration/login.html b/client/apps/city_issues/templates/registration/login.html new file mode 100644 index 0000000..4e3439e --- /dev/null +++ b/client/apps/city_issues/templates/registration/login.html @@ -0,0 +1,46 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}Log in{% endblock %} + + +{% block content %} + +{% block status_message %} + {% if form.errors %} + + {% endif %} +{% endblock %} + +
      +
      + + {% csrf_token %} + + +
      + + + {{ form.errors.username.as_data.0.0 }} +
      + +
      + + + {{ form.errors.password.as_data.0.0 }} +
      + + +
      +
      + +{% endblock content %} diff --git a/client/apps/city_issues/templates/registration/registration_base.html b/client/apps/city_issues/templates/registration/registration_base.html new file mode 100644 index 0000000..94d9808 --- /dev/null +++ b/client/apps/city_issues/templates/registration/registration_base.html @@ -0,0 +1 @@ +{% extends "base.html" %} diff --git a/client/apps/city_issues/templates/registration/registration_form.html b/client/apps/city_issues/templates/registration/registration_form.html new file mode 100644 index 0000000..0f41f14 --- /dev/null +++ b/client/apps/city_issues/templates/registration/registration_form.html @@ -0,0 +1,60 @@ +{% extends "registration/registration_base.html" %} +{% load i18n %} + +{% block title %}Registration{% endblock %} + + +{% block content %} + +{% block status_message %} + {% if form.errors %} + + {% endif %} +{% endblock %} + +
      +
      + + {% csrf_token %} + + +
      + + + {{ form.errors.email.as_text }} +
      + +
      + + + {{ form.errors.alias.as_text }} +
      + +
      + + + {{ form.errors.name.as_text }} +
      + +
      + + + {{ form.errors.password1.as_text }} +
      + +
      + + + {{ form.errors.password2.as_text }} +
      + + + +
      +
      + +{% endblock content %} diff --git a/client/apps/city_issues/templates/user/chat.html b/client/apps/city_issues/templates/user/chat.html new file mode 100644 index 0000000..540b2a7 --- /dev/null +++ b/client/apps/city_issues/templates/user/chat.html @@ -0,0 +1,40 @@ + \ No newline at end of file diff --git a/client/apps/city_issues/templates/user/edit-modal.html b/client/apps/city_issues/templates/user/edit-modal.html new file mode 100644 index 0000000..f7b8fd4 --- /dev/null +++ b/client/apps/city_issues/templates/user/edit-modal.html @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/client/apps/city_issues/templates/user/user.html b/client/apps/city_issues/templates/user/user.html new file mode 100644 index 0000000..b20879f --- /dev/null +++ b/client/apps/city_issues/templates/user/user.html @@ -0,0 +1,167 @@ +{% extends "home_page.html" %} + +{% load i18n %} + +{% block meta_title %}Issues page{% endblock meta_title %} + +{% block extra_css %} + {% load static %} + + + + + + +{% endblock extra_css %} + + +{% block content %} +
      +
      +
      + {% if messages %} +
        + {% for message in messages %} + + {{ message }} + × + + {% endfor %} +
      + {% endif %} +
      +
      +
      +

      {{ user.name|default_if_none:"Name is not defined" }}

      +
      +
      +
      +
      + User avatar +
      +
      + + + + + + + + + + + + + + + +
      Alias:{{ user.alias|default_if_none:"Alias is not defined" }}
      Role:{{ user.role.role }}
      Email{{ user.email }} +
      +
      +
      +
      + +
      +
      +
      +

      Added issues

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for issue in user_issues %} + + + + + + + + + + + + {% endfor %} + +
      #TitleCategoryStatusOpen dateClose dateModerator commentEditDetailed view
      #TitleCategoryStatusOpen dateClose dateModerator commentEditDetailed view
      {{ forloop.counter }} {{ issue.title }}{{ issue.category.category }}{{ issue.status }}{{ issue.open_date|date:"d/m/y" }}{{ issue.close_date|default_if_none:""|date:"d/m/y" }} + + + {% if issue.status == 'new' %} + + + edit + + {% endif %} + + + + show + +
      +
      +
      + +{% include 'user/edit-modal.html' %} + +{% include 'user/chat.html' %} + +{% endblock content %} + + +{% block extra_js %} + + + {% load static %} + + + +{% endblock extra_js %} diff --git a/client/apps/city_issues/templatetags/__init__.py b/client/apps/city_issues/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/templatetags/app_filters.py b/client/apps/city_issues/templatetags/app_filters.py new file mode 100644 index 0000000..5d6c19b --- /dev/null +++ b/client/apps/city_issues/templatetags/app_filters.py @@ -0,0 +1,16 @@ +from urllib import urlencode + +from django import template + +register = template.Library() + + +@register.simple_tag +def update_url(val, **kwargs): + parameters = val.copy() + if kwargs.get('order_by') == parameters.get('order_by') and parameters.get('reverse') != 'v_v': + kwargs['reverse'] = 'v_v' + elif kwargs.get('order_by') == parameters.get('order_by') and parameters.get('reverse') == 'v_v': + kwargs['reverse'] = '' + parameters.update(kwargs) + return '?' + urlencode(parameters) diff --git a/client/apps/city_issues/tests/__init__.py b/client/apps/city_issues/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/apps/city_issues/thumbnails.py b/client/apps/city_issues/thumbnails.py new file mode 100644 index 0000000..f0e6c23 --- /dev/null +++ b/client/apps/city_issues/thumbnails.py @@ -0,0 +1,18 @@ +""" +This module create thumbnails +""" +import os.path +from PIL import Image, ImageOps +import StringIO + +from django.conf import settings + + +def create_thumbnail(issue_file, title, url): + head, tail = os.path.split(url) + filename = "thumb-{}".format(tail) + + box = (360, 225) + image = Image.open(issue_file) + image = ImageOps.fit(image, box, Image.ANTIALIAS) + image.save(os.path.join(settings.MEDIA_ROOT, 'uploads', title, filename), format=image.format) diff --git a/client/apps/city_issues/user_managers.py b/client/apps/city_issues/user_managers.py new file mode 100644 index 0000000..a11cf61 --- /dev/null +++ b/client/apps/city_issues/user_managers.py @@ -0,0 +1,29 @@ +""" +User managers +""" +from django.contrib.auth.models import BaseUserManager + + +class UserManager(BaseUserManager): + """ + Creates and saves a user + """ + def _create_user(self, email, alias, name, password, **extra_fields): + if not email: + raise ValueError('The given email must be set') + elif not alias: + raise ValueError('The given alias must be set') + user = self.model(email=self.normalize_email(email), alias=alias, name=name, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, alias, name, password=None, is_staff=False, + avatar=None): + return self._create_user(email=email, password=password, alias=alias, name=name, is_staff=is_staff, + avatar=avatar) + + def create_superuser(self, email, alias, name, password, + avatar=None): + return self._create_user(email=email, password=password, alias=alias, name=name, is_superuser=True, + avatar=avatar) diff --git a/client/apps/city_issues/views.py b/client/apps/city_issues/views.py new file mode 100644 index 0000000..888e529 --- /dev/null +++ b/client/apps/city_issues/views.py @@ -0,0 +1,480 @@ +""" +Django views +""" +# -*- coding: utf-8 -*- +import json +import os.path + +from datetime import date, datetime, time +import operator + +from django import forms +from django.db.models import Q +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.decorators import login_required +from django.contrib.postgres.search import SearchVector +from django.core import serializers +from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage +from django.core.exceptions import PermissionDenied +from django.http import JsonResponse, HttpResponse, HttpResponseRedirect, Http404 +from django.shortcuts import redirect, render, get_object_or_404 +from django.utils.timezone import make_aware +from django.views import View +from django.views.generic import CreateView, FormView, ListView, TemplateView +from django.views.generic.detail import DetailView +from django.views.generic.edit import UpdateView +from django.urls import reverse +from django.utils.decorators import method_decorator + +from city_issues.models import Attachments, Issues, IssueHistory, User, \ + Comments, Category +from city_issues.forms.forms import (EditIssue, IssueFilter, IssueForm, + IssueFormEdit, IssueSearchForm, + EditUserForm, CommentsOnMapForm, + IssueFormEditWithoutStatus, + InternalCommentsForm, ModEditForm, ModCommentForm) +from city_issues.mixins import LoginRequiredMixin +from city_issues.thumbnails import create_thumbnail + +ROLE_ADMIN = 1 +ROLE_MODERATOR = 2 +ROLE_USER = 3 + + +class HomePageView(TemplateView): + """Home page""" + template_name = "home_page.html" + + def get(self, request): + return redirect("map") + + +class UserProfileView(View): + """User profile page""" + form_class = EditUserForm + form_comments_class = InternalCommentsForm + success_url = 'user_profile' + template_name = 'user/user.html' + + def get(self, request): + user = request.user + user_issues = Issues.objects.filter(user_id=user.id) + form = self.form_class(instance=User.objects.get(id=user.id)) + + return render(request, self.template_name, {'user': user, + 'user_issues': user_issues, + 'form': form, + 'comments_form': self.form_comments_class}) + + def post(self, request): + user = User.objects.get(id=request.user.id) + form = EditUserForm(data=request.POST, instance=request.user) + + if form.is_valid(): + user.name = form.cleaned_data['name'] + user.alias = form.cleaned_data['alias'] + user.email = form.cleaned_data['email'] + user.set_password(form.cleaned_data['confirm_password']) + user.save() + update_session_auth_hash(request, user) + messages.success(request, 'Changes successfully saved') + + else: + messages.error(request, form.errors) + return render(request, self.template_name, + {'form': form, 'has_error': 'error'}) + + return redirect(self.success_url) + + @classmethod + def get_internal_comments(cls, request, issue_id): + internal_comments = Comments.objects.filter(issue_id=issue_id, + status='internal').select_related( + "user_id").values('comment', 'user_id', 'user__alias', + 'date_public') + + return JsonResponse({'comments': list(internal_comments)}) + + @classmethod + def store_internal_comments(cls, request, issue_id): + form = UserProfileView.form_comments_class(request.POST) + + if form.is_valid(): + comment = Comments() + comment.comment = form.cleaned_data['comment'] + comment.issue_id = issue_id + comment.user_id = request.user.id + comment.status = 'internal' + comment.date_public = datetime.now() + comment.save() + + return JsonResponse({'answer': 'success'}) + else: + return JsonResponse(form.errors.as_json(), status=400, content_type='application/json', safe=False) + + +class IssueCreate(CreateView): + """Create new issue""" + MAX_FILE_SIZE = 5242880 + + model = Issues + form_class = IssueForm + template_name = 'issues/issues.html' + success_url = 'map' + + def form_valid(self, form): + form.instance.user = self.request.user + issue = form.save(commit=True) + + if form.files: + self.save_files(form, form.files.getlist('files'), issue) + + self.save_issue_history(issue, form.instance.user) + messages.success(self.request, 'Issue was successfully saved') + return super(IssueCreate, self).form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, form.errors) + return super(IssueCreate, self).form_invalid(form) + + def save_files(self, form, files, issue): + for issue_file in files: + if issue_file._size > self.MAX_FILE_SIZE: + messages.error(self.request, 'Max file size : 5MB') + return super(IssueCreate, self).form_valid(form) + else: + attachment = Attachments() + attachment.issue = issue + attachment.image_url = issue_file + attachment.save() + create_thumbnail(issue_file, issue.title, + attachment.image_url.url) + + def save_issue_history(self, issue, user): + issue_history = IssueHistory() + issue_history.issue = issue + issue_history.user = user + issue_history.save() + + +def map_page_view(request): + """Map page""" + form = IssueFilter() + if request.user.is_anonymous(): + form.fields.pop('show_deleted') + form.fields.pop('show_on_moderation') + form.fields.pop('show_new') + form.fields.pop('show_pending_close') + + if request.user.is_authenticated() and request.user.role.id not in ( + ROLE_ADMIN, ROLE_MODERATOR): + form.fields.pop('show_deleted') + + comment_form = CommentsOnMapForm() + return render(request, 'map_page.html', + {'form': form, 'comment_form': comment_form}) + + +def get_issue_data(request, issue_id): + """Returns single issue record as json""" + single_issue = Issues() + data = json.dumps(single_issue.get_issue_data_by_id(request, issue_id)) + return JsonResponse(data, safe=False) + + +def get_all_issues_data(request): + """Returns all issues records as json with possible filter.""" + all_issues = Issues() + role_based_query = all_issues.get_role_based_query(request) + role_based_query_default_show = role_based_query.filter( + status__in=["open", "new", "on moderation"]) + + data = serializers.serialize( + "json", + role_based_query_default_show) + + form = IssueFilter(request.GET) + + if form.is_valid() and form.data.get('filter'): + query = all_issues.issue_filter(form, role_based_query) + data = serializers.serialize("json", query) + + return JsonResponse(data, safe=False) + + +class CheckIssues(ListView, FormView): + """A list of issues""" + form_class = IssueSearchForm + template_name = 'issues_list.html' + model = Issues + context_object_name = 'issues_list' + paginate_by = 8 + + def get_queryset(self): + """Adds sorting""" + queryset = super(CheckIssues, self).get_queryset() + order_by = self.request.GET.get('order_by') + search = self.request.GET.get('search') + + if search: + query_list = search.split() + queryset = queryset.filter( + reduce(operator.or_, + (Q(title__icontains=q) for q in query_list)) | + reduce(operator.or_, (Q(description__icontains=q) + for q in query_list)) + ) + if order_by in ('title', 'status', 'user', 'category', 'open_date'): + queryset = queryset.order_by(order_by) + if self.request.GET.get('reverse', '') == 'v_v': + queryset = queryset.reverse() + return queryset + + def get_context_data(self, **kwargs): + context = super(CheckIssues, self).get_context_data(**kwargs) + context['issues_range'] = range(context["paginator"].num_pages) + return context + + +class DetailedIssue(DetailView): + """Detailed issue""" + template_name = 'issue_detailed.html' + model = Issues + + +class UpdateIssue(IssueCreate, UpdateView): + """Edit issue from map.""" + model = Issues + form_class = IssueFormEdit + template_name = 'edit_issue.html' + success_url = '/map/' + + def dispatch(self, request, *args, **kwargs): + obj = self.get_object() + if request.user.is_authenticated() and ( + (self.request.user.role.id in (ROLE_ADMIN, ROLE_MODERATOR)) or + (obj.user == self.request.user)): + if self.request.user.role.id not in (ROLE_ADMIN, ROLE_MODERATOR): + self.form_class = IssueFormEditWithoutStatus + return super(UpdateIssue, self).dispatch(request, *args, **kwargs) + raise PermissionDenied("You are not allowed to edit this issue") + + +class CommentIssues(CreateView): + """Comment issue""" + template_name = 'issue_detailed.html' + model = Comments + fields = ['comment', 'status'] + + def get_context_data(self, **kwargs): + context = super(CommentIssues, self).get_context_data(**kwargs) + context['object'] = Issues.objects.get(pk=self.kwargs['pk']) + return context + + def form_valid(self, form): + if self.request.user.is_authenticated(): + form = form.save(commit=False) + issue = Issues.objects.get(pk=self.kwargs['pk']) + user = User.objects.get(pk=self.request.user.id) + form.issue = issue + form.user = user + form.save() + return redirect( + reverse('issue-comment', kwargs={'pk': self.kwargs['pk']}) + ) + return super(CommentIssues, self).form_invalid(form) + + +def delete_attachment(request): + attachment_id = request.POST.get('attachment-id') + attachment = Attachments.objects.get(id=attachment_id) + attachment.delete() + + messages.success(request, 'Attachment successfully deleted') + + return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/')) + + +def post_comment(request, issue_id): + if request.user.is_authenticated(): + form = CommentsOnMapForm(request.POST) + + if form.is_valid(): + issue = Issues() + list_of_comments_statuses = issue.get_actions_list( + request, issue_id)['list_of_comments_statuses'] + comment_form_status = form.cleaned_data.get('status') + if comment_form_status and comment_form_status in list_of_comments_statuses: + comment = Comments() + comment.comment = form.cleaned_data.get('comment') + comment.user = request.user + comment.status = comment_form_status + comment.issue = Issues.objects.get(pk=issue_id) + comment.date_public = datetime.now() + comment.save() + + comments_list = Comments() + comments_query = comments_list.get_comments( + issue_id, list_of_comments_statuses) + + data = json.dumps(comments_query) + return JsonResponse(data, safe=False) + + raise PermissionDenied("You are not allowed to comment without login") + + +def issue_action(request, issue_id): + """Makes actions with issues""" + if request.user.is_authenticated(): + issue = Issues() + list_of_actions = issue.get_actions_list( + request, issue_id)['list_of_actions'] + action = request.POST.get('action') + if action and action in list_of_actions: + changing_issue = Issues.objects.get(pk=issue_id) + changing_issue.status = action + changing_issue.save() + return JsonResponse({'result': 'success'}, safe=False) + + raise PermissionDenied("You are not allowed to change issue") + + +def mod_list_panel(request): + """ A moderator list of issues""" + if request.user.is_superuser or request.user.is_staff: + issues_list = Issues.objects.order_by('-open_date') + context = {'title': 'Moderator Panel:'} + + order_by = request.GET.get('order_by', '') + if order_by in ('title', 'status', 'user', 'category', 'open_date', 'delete_date'): + issues_list = issues_list.order_by(order_by) + if request.GET.get('reverse', '') == '1': + issues_list = issues_list.reverse() + + query = request.GET.get('q') + if query is not None: + issues_list = Issues.objects.annotate(search=SearchVector('title', 'status', 'category__category', + config='english')).filter(search=query) + context['last_query'] = '?q=%s' % query + + current_page = Paginator(issues_list, 10) + page = request.GET.get('page') + try: + context['issues_list'] = current_page.page(page) + except PageNotAnInteger: + + context['issues_list'] = current_page.page(1) + except EmptyPage: + + context['issues_list'] = current_page.page(current_page.num_pages) + + if page == '1': + return redirect('modpanel', permanent=True) + else: + context = {'title': 'You have not permission to this page'} + return render(request, 'mod/permission.html', context) + return render(request, 'mod/mod_list.html', context) + + +@login_required +def mod_edit_issue(request, pk=None): + """A moderator edit issues""" + issues = get_object_or_404(Issues, pk=pk) + form = ModEditForm(request.POST or None, instance=issues) + if form.is_valid(): + issues = form.save(commit=False) + issues.save() + messages.success(request, "Successfully Update") + return HttpResponseRedirect(issues.get_absolute_url()) + context = { + "title": issues.title, + "issue": issues, + "form": form, + } + return render(request, "mod/mod_edit.html", context) + + +@login_required +def mod_comment(request, pk=None): + """A moderator comments""" + issue = Issues.objects.get(pk=pk) + if request.method != 'POST': + form = ModCommentForm() + else: + form = ModCommentForm(data=request.POST) + if form.is_valid(): + new_comment = form.save(commit=False) + new_comment.issue = issue + new_comment.save() + return HttpResponseRedirect(reverse('modcomment', + args=[pk])) + + context = {'issue': issue, 'form': form} + return render(request, 'mod/mod_comments.html', context) + + +@login_required +def delete_issue(request, pk): + """Route for deleting issue.""" + issue = get_object_or_404(Issues, pk=pk) + if request.method == "POST": + issue.mod_delete() + issue.save() + return redirect("modpanel") + context = { + "issue": issue + } + return redirect(reverse('modpanel', context)) + + +@login_required +def restore_issue(request, pk): + """Route for restoring issue.""" + issue = get_object_or_404(Issues, pk=pk) + if request.method == "POST": + issue.mod_restore() + issue.save() + return redirect("modpanel") + context = { + "issue": issue + } + return redirect(reverse('modpanel', context)) + + +def comment_delete(request, issue_id, comment_id): + if request.user.is_authenticated(): + comment = Comments.objects.get(pk=comment_id) + + if request.user.role.id in ( + ROLE_ADMIN, ROLE_MODERATOR) or comment.user_id == request.user.id: + comment.pre_deletion_status = comment.status + comment.status = "deleted" + comment.save() + return redirect(reverse('issue-comment', args=[issue_id])) + + raise PermissionDenied("You are not allowed to delete comments") + + +def comment_restore(request, issue_id, comment_id): + if request.user.is_authenticated(): + comment = Comments.objects.get(pk=comment_id) + + if request.user.role.id in ( + ROLE_ADMIN, ROLE_MODERATOR) or comment.user_id == request.user.id: + + comment.status = 'public' + if comment.pre_deletion_status: + comment.status = comment.pre_deletion_status + comment.save() + return redirect(reverse('issue-comment', args=[issue_id])) + + raise PermissionDenied("You are not allowed to delete comments") + + +def imgResponse(request, path): + if os.path.exists(path): + with open(path, "rb") as f: + return HttpResponse(f.read(), content_type="image/jpeg") + raise Http404 diff --git a/client/basic/__init__.py b/client/basic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/basic/local_settings.py.example b/client/basic/local_settings.py.example new file mode 100644 index 0000000..2c0cea5 --- /dev/null +++ b/client/basic/local_settings.py.example @@ -0,0 +1,19 @@ +# Rename the module into local_settings.py and substitute the name +# and user, password, host, port. Good luck! + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'ezj+e4m@auyzke$5)rnc%r!3%enk5%!lmwb22^md2_okgnqem0' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'database_name', + 'USER': 'user', + 'PASSWORD': 'password', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } +} diff --git a/client/basic/settings.py b/client/basic/settings.py new file mode 100644 index 0000000..bcaf587 --- /dev/null +++ b/client/basic/settings.py @@ -0,0 +1,188 @@ +""" +Django settings for basic project. + +Generated by 'django-admin startproject' using Django 1.11.7. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os +import sys + +import dj_database_url +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Add visibility for base directory to django + +sys.path.insert(1, os.path.dirname(BASE_DIR)) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# Path to the custom Django applications. +# ** It is necessary! If a third-party app is just copied to the apps/ but not +# installed through the pip. +sys.path.insert(1, os.path.join(BASE_DIR, 'apps')) + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'bootstrap3', + 'city_issues', + 'django.contrib.sites', + 'registration', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.postgres', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', +] + +ROOT_URLCONF = 'basic.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'basic.wsgi.application' + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Password hashing + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', +] + + +# Add a custom model +AUTH_USER_MODEL = 'city_issues.User' + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static') + + +# Registration settings +REGISTRATION_OPEN = True +SITE_ID = 1 +REGISTRATION_FORM = 'city_issues.forms.forms.RegisterUserForm' + + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'apps', 'city_issues', 'media') + +if 'SECRET_KEY' in os.environ: + SECRET_KEY = os.environ['SECRET_KEY'] + +# Expand the default settings. +# Loading extension parameters of standard configurations +try: + from local_settings import * +except ImportError: + pass + +if 'DEBUG' in os.environ and os.environ['DEBUG'] == 'False': + + DEBUG = False + + ALLOWED_HOSTS = [".herokuapp.com"] + + STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static')) + + MEDIA_URL = '/media/app/client/media/' + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + + DATABASES = {'default': {}} + DATABASES['default']['ENGINE'] = 'django.db.backends.postgresql' + db_from_env = dj_database_url.config(conn_max_age=500) + DATABASES['default'].update(db_from_env) + + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + }, + } diff --git a/client/basic/urls.py b/client/basic/urls.py new file mode 100644 index 0000000..2bef37e --- /dev/null +++ b/client/basic/urls.py @@ -0,0 +1,65 @@ +"""basic URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.11/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.contrib.auth import views as auth_views +from django.conf import settings +from django.conf.urls import include, url +from django.conf.urls.static import static +from django.contrib.auth.decorators import login_required +from django.views.generic import RedirectView + +from city_issues.views import ( + CheckIssues, DetailedIssue, delete_attachment, get_all_issues_data, get_issue_data, + HomePageView, map_page_view, IssueCreate, UserProfileView, UpdateIssue, CommentIssues, + post_comment, issue_action, comment_delete, comment_restore, mod_list_panel, mod_edit_issue, + delete_issue, restore_issue, imgResponse, mod_comment) + + +urlpatterns = [ + url(r'^$', HomePageView.as_view(), name='home'), + url(r'^issues/$', CheckIssues.as_view(), name='issues'), + url(r'^issue-comment/(?P[0-9]+)/$', CommentIssues.as_view(), name='issue-comment'), + url(r'^issue/(?P\d+)/$', DetailedIssue.as_view(), name='issue'), + url(r'^delete-attachment/$', delete_attachment, name='delete-attachment'), + url(r'^postcomment/(?P[0-9]+)/$', post_comment, name='post-comment'), + url(r'^issueaction/(?P[0-9]+)/$', issue_action, name='issue-action'), + url(r'^modpanel/$', mod_list_panel, name='modpanel'), + url(r'^modpanel/(?P\d+)/edit/$', mod_edit_issue, name='mod_edit'), + url(r'^modpanel/(?P\d+)/delete/$', delete_issue, name='delete_issue'), + url(r'^modpanel/(?P\d+)/restore/$', restore_issue, name='restore_issue'), + url(r'^modcomment/(?P\d+)/$', mod_comment, name='modcomment'), + url(r'^deletecomment/(?P[0-9]+)/(?P[0-9]+)/$', comment_delete, name='comment-delete'), + url(r'^restorecomment/(?P[0-9]+)/(?P[0-9]+)/$', comment_restore, name='comment-restore'), + url(r'^internal-comments/(?P[0-9]+)/$', UserProfileView.get_internal_comments, name='internal-comment'), + url(r'^store/internal-comments/(?P[0-9]+)/$', UserProfileView.store_internal_comments, name='store-internal-comment'), + + url(r'^map/$', map_page_view, name='map'), + url(r'^map/getissuebyid/(?P[0-9]+)$', + get_issue_data, name='issue_data'), + url(r'^map/getissuesall/$', get_all_issues_data, name='all_issues'), + url(r'^add-issue', login_required(IssueCreate.as_view()), name='create_issue'), + url(r'^editissue/(?P[0-9]+)$', UpdateIssue.as_view(), name='edit_issue'), + + # registration and authorization views + url(r'^accounts/logout/$', auth_views.logout, kwargs={'next_page': 'home'}, name='auth_logout'), + url(r'^accounts/profile/$', UserProfileView.as_view(), name='user_profile'), + url(r'^accounts/', include('registration.backends.simple.urls', namespace='accounts', )), +] + +if settings.DEBUG is True: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + +if settings.DEBUG is False: + urlpatterns.append(url(r'^media(?P.*)$', imgResponse, name='media')) diff --git a/client/basic/wsgi.py b/client/basic/wsgi.py new file mode 100644 index 0000000..778484d --- /dev/null +++ b/client/basic/wsgi.py @@ -0,0 +1,18 @@ +""" +WSGI config for basic project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application +from whitenoise.django import DjangoWhiteNoise + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "basic.settings") + +application = get_wsgi_application() +application = DjangoWhiteNoise(application) diff --git a/client/manage.py b/client/manage.py new file mode 100644 index 0000000..7b32df9 --- /dev/null +++ b/client/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "basic.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/pylint_check.py b/pylint_check.py new file mode 100644 index 0000000..7a503db --- /dev/null +++ b/pylint_check.py @@ -0,0 +1,43 @@ +import argparse +import sys + +from pylint.lint import Run + +parser = argparse.ArgumentParser( + description='Make pylint to pass with custom score.') +parser.add_argument('-t', '--targets', nargs='+', dest='targets', + help='space separated paths to target modules or packages') +parser.add_argument('-s', '--score', type=float, dest='score', + default=7.0, help='float number, the affordable pylint score') +parser.add_argument('-l', '--load', dest='load_plugins', + help='load some plugins') + +args = parser.parse_args() +args.load_plugins = ''.join(['--load-plugins=', args.load_plugins]) +args.targets.append(args.load_plugins) + + +def _check_score(lint_results, score): + if lint_results.linter.stats['global_note'] < score: + print "Your code has been rated too low, expected score {} and more".format(score) + sys.exit(1) + + +def _check_critical(lint_results): + fatals = lint_results.linter.stats['fatal'] + errors = lint_results.linter.stats['error'] + if fatals > 0 or errors > 0: + print "Encountered {0} fatals and {1} errors".format(fatals, errors) + sys.exit(1) + + +def lint(targets, score): + results = Run(targets, exit=False) + _check_critical(results) + _check_score(results, score) + print "Pylint successful" + sys.exit(0) + + +if __name__ == '__main__': + lint(args.targets, args.score) diff --git a/requirements.txt b/requirements.txt index 68151ce..212d80c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,42 @@ alembic==0.9.6 +bcrypt==3.1.4 +blinker==1.4 +cffi==1.11.2 click==6.7 +configparser==3.5.0 +Django==1.11.7 +django-registration-redux==1.8 +django-bootstrap3==9.1.0 +dj-database-url==0.4.2 +django-imagekit==4.0.2 +django-storages==1.6.5 +dominate==2.3.1 +gunicorn==19.7.1 Flask==0.12.2 +Flask-Bootstrap==3.3.7.1 Flask-Migrate==2.1.1 -Flask-Script==2.0.6 Flask-SQLAlchemy==2.3.2 Flask-WTF==0.14.2 +Flask-Mail==0.9.1 inflect==0.2.5 itsdangerous==0.24 Jinja2==2.9.6 Mako==1.0.7 MarkupSafe==1.0 -pep8==1.7.1 +olefile==0.44 +passlib==1.7.1 +Pillow==4.3.0 psycopg2==2.7.3.2 +pycparser==2.18 +pylint-django==0.7.2 python-dateutil==2.6.1 python-editor==1.0.3 +pytz==2017.3 six==1.11.0 sqlacodegen==1.1.6 SQLAlchemy==1.1.14 SQLAlchemy-Utils==0.32.19 +visitor==0.1.3 Werkzeug==0.12.2 +whitenoise==3.3.1 WTForms==2.1 diff --git a/requirements/common.txt b/requirements/common.txt new file mode 100644 index 0000000..b904e78 --- /dev/null +++ b/requirements/common.txt @@ -0,0 +1,45 @@ +# ============= +# Django +# ============= +django-bootstrap3==9.1.0 +dj-database-url==0.4.2 +django-imagekit==4.0.2 +django-storages==1.6.5 +django-registration-redux==1.8 +Django==1.11.7 +olefile==0.44 +passlib==1.7.1 +Pillow==4.3.0 +pytz==2017.3 +# ============== +# Flask +# ============== +alembic==0.9.6 +bcrypt==3.1.4 +blinker==1.4 +cffi==1.11.2 +click==6.7 +configparser==3.5.0 +dominate==2.3.1 +Flask-Bootstrap==3.3.7.1 +Flask-Migrate==2.1.1 +Flask-SQLAlchemy==2.3.2 +Flask-WTF==0.14.2 +Flask==0.12.2 +Flask-Mail==0.9.1 +inflect==0.2.5 +itsdangerous==0.24 +Jinja2==2.9.6 +Mako==1.0.7 +MarkupSafe==1.0 +psycopg2==2.7.3.2 +pycparser==2.18 +python-dateutil==2.6.1 +python-editor==1.0.3 +six==1.11.0 +sqlacodegen==1.1.6 +SQLAlchemy-Utils==0.32.19 +SQLAlchemy==1.1.14 +visitor==0.1.3 +Werkzeug==0.12.2 +WTForms==2.1 \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..6dc2fb1 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,4 @@ +-r common.txt +# Django +pylint-django==0.7.2 +# Flask \ No newline at end of file diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..c3899b0 --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1 @@ +-r common.txt \ No newline at end of file diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 0000000..059d4e8 --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1,2 @@ +-r common.txt +# Tests extension diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..2ce112e --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-2.7.14 \ No newline at end of file