diff --git a/README.md b/README.md
index 080ae1a..d9ff419 100644
--- a/README.md
+++ b/README.md
@@ -34,13 +34,13 @@ $ export DJANGO_SETTINGS_MODULE=ojusomap.settings
## Install the Python Dependencies
```bash
-$ pip3 install -r requirements.txt
+$ pip3 install -r requirements-devel.txt
```
If you run into issues with `psycopg2` you may need to run the following:
```bash
-$ pip uninstall psycopg2 && pip install --no-binary :all: psycopg2
+$ pip3 uninstall psycopg2 && pip3 install --no-binary :all: psycopg2
```
## Run The Migrations
diff --git a/apps/files/__init__.py b/apps/files/__init__.py
new file mode 100644
index 0000000..db621fa
--- /dev/null
+++ b/apps/files/__init__.py
@@ -0,0 +1,3 @@
+""" AJAX file uploads
+
+based on https://simpleisbetterthancomplex.com/tutorial/2016/11/22/django-multiple-file-upload-using-ajax.html """
diff --git a/apps/files/admin.py b/apps/files/admin.py
new file mode 100644
index 0000000..d90b977
--- /dev/null
+++ b/apps/files/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+
+from .models import File
+
+admin.site.register(File)
diff --git a/apps/files/apps.py b/apps/files/apps.py
new file mode 100644
index 0000000..c86f272
--- /dev/null
+++ b/apps/files/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class FilesConfig(AppConfig):
+ name = 'files'
diff --git a/apps/files/forms.py b/apps/files/forms.py
new file mode 100644
index 0000000..9f0ada2
--- /dev/null
+++ b/apps/files/forms.py
@@ -0,0 +1,9 @@
+from django import forms
+
+from .models import File
+
+
+class FileForm(forms.ModelForm):
+ class Meta:
+ model = File
+ exclude = ['user',]
diff --git a/apps/files/migrations/0001_initial.py b/apps/files/migrations/0001_initial.py
new file mode 100644
index 0000000..8e39e2a
--- /dev/null
+++ b/apps/files/migrations/0001_initial.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-04-23 02:20
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='File',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('file', models.FileField(upload_to='.')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/apps/files/migrations/0002_file_user.py b/apps/files/migrations/0002_file_user.py
new file mode 100644
index 0000000..12cf0f9
--- /dev/null
+++ b/apps/files/migrations/0002_file_user.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-04-29 22:07
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('files', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='file',
+ name='user',
+ field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='files', to=settings.AUTH_USER_MODEL),
+ preserve_default=False,
+ ),
+ ]
diff --git a/apps/files/migrations/__init__.py b/apps/files/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/files/models.py b/apps/files/models.py
new file mode 100644
index 0000000..57a123d
--- /dev/null
+++ b/apps/files/models.py
@@ -0,0 +1,23 @@
+from django.contrib.auth.models import User
+from django.db import models
+
+from apps.map.models import CaseStudy, CaseStudyDraft
+
+
+class BaseFile(models.Model):
+ file = models.FileField(
+ upload_to='.',
+ )
+ user = models.ForeignKey(
+ User, related_name='files'
+ )
+
+ class Meta:
+ abstract = True
+
+ def __str__(self):
+ return self.file.name
+
+
+class File(BaseFile):
+ pass
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/.gitignore b/apps/files/static/files/jQuery-File-Upload-9.21.0/.gitignore
new file mode 100644
index 0000000..29a41a8
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/.gitignore
@@ -0,0 +1,3 @@
+.DS_Store
+*.pyc
+node_modules
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/.jshintrc b/apps/files/static/files/jQuery-File-Upload-9.21.0/.jshintrc
new file mode 100644
index 0000000..4ad82e6
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/.jshintrc
@@ -0,0 +1,81 @@
+{
+ "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.)
+ "camelcase" : true, // true: Identifiers must be in camelCase
+ "curly" : true, // true: Require {} for every new block or scope
+ "eqeqeq" : true, // true: Require triple equals (===) for comparison
+ "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty()
+ "immed" : true, // true: Require immediate invocations to be wrapped in parens
+ // e.g. `(function () { } ());`
+ "indent" : 4, // {int} Number of spaces to use for indentation
+ "latedef" : true, // true: Require variables/functions to be defined before being used
+ "newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()`
+ "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee`
+ "noempty" : true, // true: Prohibit use of empty blocks
+ "nonew" : true, // true: Prohibit use of constructors for side-effects (without assignment)
+ "plusplus" : false, // true: Prohibit use of `++` & `--`
+ "quotmark" : "single", // Quotation mark consistency:
+ // false : do nothing (default)
+ // true : ensure whatever is used is consistent
+ // "single" : require single quotes
+ // "double" : require double quotes
+ "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks)
+ "unused" : true, // true: Require all defined variables be used
+ "strict" : true, // true: Requires all functions run in ES5 Strict Mode
+ "trailing" : true, // true: Prohibit trailing whitespaces
+ "maxparams" : false, // {int} Max number of formal params allowed per function
+ "maxdepth" : false, // {int} Max depth of nested blocks (within functions)
+ "maxstatements" : false, // {int} Max number statements per function
+ "maxcomplexity" : false, // {int} Max cyclomatic complexity per function
+ "maxlen" : false, // {int} Max number of characters per line
+
+ // Relaxing
+ "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons)
+ "boss" : false, // true: Tolerate assignments where comparisons would be expected
+ "debug" : false, // true: Allow debugger statements e.g. browser breakpoints.
+ "eqnull" : false, // true: Tolerate use of `== null`
+ "es5" : false, // true: Allow ES5 syntax (ex: getters and setters)
+ "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`)
+ "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features)
+ // (ex: `for each`, multiple try/catch, function expression…)
+ "evil" : false, // true: Tolerate use of `eval` and `new Function()`
+ "expr" : false, // true: Tolerate `ExpressionStatement` as Programs
+ "funcscope" : false, // true: Tolerate defining variables inside control statements"
+ "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict')
+ "iterator" : false, // true: Tolerate using the `__iterator__` property
+ "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block
+ "laxbreak" : false, // true: Tolerate possibly unsafe line breakings
+ "laxcomma" : false, // true: Tolerate comma-first style coding
+ "loopfunc" : false, // true: Tolerate functions being defined in loops
+ "multistr" : false, // true: Tolerate multi-line strings
+ "proto" : false, // true: Tolerate using the `__proto__` property
+ "scripturl" : false, // true: Tolerate script-targeted URLs
+ "smarttabs" : false, // true: Tolerate mixed tabs/spaces when used for alignment
+ "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;`
+ "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation
+ "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;`
+ "validthis" : false, // true: Tolerate using this in a non-constructor function
+
+ // Environments
+ "browser" : false, // Web Browser (window, document, etc)
+ "couch" : false, // CouchDB
+ "devel" : false, // Development/debugging (alert, confirm, etc)
+ "dojo" : false, // Dojo Toolkit
+ "jquery" : false, // jQuery
+ "mootools" : false, // MooTools
+ "node" : false, // Node.js
+ "nonstandard" : false, // Widely adopted globals (escape, unescape, etc)
+ "prototypejs" : false, // Prototype and Scriptaculous
+ "rhino" : false, // Rhino
+ "worker" : false, // Web Workers
+ "wsh" : false, // Windows Scripting Host
+ "yui" : false, // Yahoo User Interface
+
+ // Legacy
+ "nomen" : true, // true: Prohibit dangling `_` in variables
+ "onevar" : true, // true: Allow only one `var` statement per function
+ "passfail" : false, // true: Stop on first error
+ "white" : true, // true: Check against strict whitespace and indentation rules
+
+ // Custom Globals
+ "globals" : {} // additional predefined global variables
+}
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/.npmignore b/apps/files/static/files/jQuery-File-Upload-9.21.0/.npmignore
new file mode 100644
index 0000000..0530f5d
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/.npmignore
@@ -0,0 +1,20 @@
+*
+!css/jquery.fileupload-noscript.css
+!css/jquery.fileupload-ui-noscript.css
+!css/jquery.fileupload-ui.css
+!css/jquery.fileupload.css
+!img/loading.gif
+!img/progressbar.gif
+!js/cors/jquery.postmessage-transport.js
+!js/cors/jquery.xdr-transport.js
+!js/vendor/jquery.ui.widget.js
+!js/jquery.fileupload-angular.js
+!js/jquery.fileupload-audio.js
+!js/jquery.fileupload-image.js
+!js/jquery.fileupload-jquery-ui.js
+!js/jquery.fileupload-process.js
+!js/jquery.fileupload-ui.js
+!js/jquery.fileupload-validate.js
+!js/jquery.fileupload-video.js
+!js/jquery.fileupload.js
+!js/jquery.iframe-transport.js
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/CONTRIBUTING.md b/apps/files/static/files/jQuery-File-Upload-9.21.0/CONTRIBUTING.md
new file mode 100644
index 0000000..e182f9b
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/CONTRIBUTING.md
@@ -0,0 +1,15 @@
+Please follow these pull request guidelines:
+
+1. Update your fork to the latest upstream version.
+
+2. Follow the coding conventions of the original source files (indentation, spaces, brackets layout).
+
+3. Code changes must pass JSHint validation with the `.jshintrc` settings of this project.
+
+4. Code changes must pass the QUnit tests defined in the `test` folder.
+
+5. New features should be covered by accompanying QUnit tests.
+
+6. Keep your commits as atomic as possible, i.e. create a new commit for every single bug fix or feature added.
+
+7. Always add meaningful commit messages.
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/LICENSE.txt b/apps/files/static/files/jQuery-File-Upload-9.21.0/LICENSE.txt
new file mode 100644
index 0000000..87a6446
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright © 2010 Sebastian Tschan, https://blueimp.net
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/README.md b/apps/files/static/files/jQuery-File-Upload-9.21.0/README.md
new file mode 100644
index 0000000..76bdf89
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/README.md
@@ -0,0 +1,107 @@
+# jQuery File Upload Plugin
+
+## Demo
+[Demo File Upload](https://blueimp.github.io/jQuery-File-Upload/)
+
+## Description
+File Upload widget with multiple file selection, drag&drop support, progress bars, validation and preview images, audio and video for jQuery.
+Supports cross-domain, chunked and resumable file uploads and client-side image resizing. Works with any server-side platform (PHP, Python, Ruby on Rails, Java, Node.js, Go etc.) that supports standard HTML form file uploads.
+
+## Setup
+* [How to setup the plugin on your website](https://github.com/blueimp/jQuery-File-Upload/wiki/Setup)
+* [How to use only the basic plugin (minimal setup guide).](https://github.com/blueimp/jQuery-File-Upload/wiki/Basic-plugin)
+
+## Features
+* **Multiple file upload:**
+ Allows to select multiple files at once and upload them simultaneously.
+* **Drag & Drop support:**
+ Allows to upload files by dragging them from your desktop or filemanager and dropping them on your browser window.
+* **Upload progress bar:**
+ Shows a progress bar indicating the upload progress for individual files and for all uploads combined.
+* **Cancelable uploads:**
+ Individual file uploads can be canceled to stop the upload progress.
+* **Resumable uploads:**
+ Aborted uploads can be resumed with browsers supporting the Blob API.
+* **Chunked uploads:**
+ Large files can be uploaded in smaller chunks with browsers supporting the Blob API.
+* **Client-side image resizing:**
+ Images can be automatically resized on client-side with browsers supporting the required JS APIs.
+* **Preview images, audio and video:**
+ A preview of image, audio and video files can be displayed before uploading with browsers supporting the required APIs.
+* **No browser plugins (e.g. Adobe Flash) required:**
+ The implementation is based on open standards like HTML5 and JavaScript and requires no additional browser plugins.
+* **Graceful fallback for legacy browsers:**
+ Uploads files via XMLHttpRequests if supported and uses iframes as fallback for legacy browsers.
+* **HTML file upload form fallback:**
+ Allows progressive enhancement by using a standard HTML file upload form as widget element.
+* **Cross-site file uploads:**
+ Supports uploading files to a different domain with cross-site XMLHttpRequests or iframe redirects.
+* **Multiple plugin instances:**
+ Allows to use multiple plugin instances on the same webpage.
+* **Customizable and extensible:**
+ Provides an API to set individual options and define callback methods for various upload events.
+* **Multipart and file contents stream uploads:**
+ Files can be uploaded as standard "multipart/form-data" or file contents stream (HTTP PUT file upload).
+* **Compatible with any server-side application platform:**
+ Works with any server-side platform (PHP, Python, Ruby on Rails, Java, Node.js, Go etc.) that supports standard HTML form file uploads.
+
+## Requirements
+
+### Mandatory requirements
+* [jQuery](https://jquery.com/) v. 1.6+
+* [jQuery UI widget factory](https://api.jqueryui.com/jQuery.widget/) v. 1.9+ (included): Required for the basic File Upload plugin, but very lightweight without any other dependencies from the jQuery UI suite.
+* [jQuery Iframe Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.iframe-transport.js) (included): Required for [browsers without XHR file upload support](https://github.com/blueimp/jQuery-File-Upload/wiki/Browser-support).
+
+### Optional requirements
+* [JavaScript Templates engine](https://github.com/blueimp/JavaScript-Templates) v. 2.5.4+: Used to render the selected and uploaded files for the Basic Plus UI and jQuery UI versions.
+* [JavaScript Load Image library](https://github.com/blueimp/JavaScript-Load-Image) v. 1.13.0+: Required for the image previews and resizing functionality.
+* [JavaScript Canvas to Blob polyfill](https://github.com/blueimp/JavaScript-Canvas-to-Blob) v. 2.1.1+:Required for the image previews and resizing functionality.
+* [blueimp Gallery](https://github.com/blueimp/Gallery) v. 2.15.1+: Used to display the uploaded images in a lightbox.
+* [Bootstrap](http://getbootstrap.com/) v. 3.2.0+
+* [Glyphicons](http://glyphicons.com/)
+
+The user interface of all versions, except the jQuery UI version, is built with [Bootstrap](http://getbootstrap.com/) and icons from [Glyphicons](http://glyphicons.com/).
+
+### Cross-domain requirements
+[Cross-domain File Uploads](https://github.com/blueimp/jQuery-File-Upload/wiki/Cross-domain-uploads) using the [Iframe Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.iframe-transport.js) require a redirect back to the origin server to retrieve the upload results. The [example implementation](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/main.js) makes use of [result.html](https://github.com/blueimp/jQuery-File-Upload/blob/master/cors/result.html) as a static redirect page for the origin server.
+
+The repository also includes the [jQuery XDomainRequest Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/cors/jquery.xdr-transport.js), which enables limited cross-domain AJAX requests in Microsoft Internet Explorer 8 and 9 (IE 10 supports cross-domain XHR requests).
+The XDomainRequest object allows GET and POST requests only and doesn't support file uploads. It is used on the [Demo](https://blueimp.github.io/jQuery-File-Upload/) to delete uploaded files from the cross-domain demo file upload service.
+
+### Custom Backends
+
+You can add support for various backends by adhering to the specification [outlined here](https://github.com/blueimp/jQuery-File-Upload/wiki/JSON-Response).
+
+## Browsers
+
+### Desktop browsers
+The File Upload plugin is regularly tested with the latest browser versions and supports the following minimal versions:
+
+* Google Chrome
+* Apple Safari 4.0+
+* Mozilla Firefox 3.0+
+* Opera 11.0+
+* Microsoft Internet Explorer 6.0+
+
+### Mobile browsers
+The File Upload plugin has been tested with and supports the following mobile browsers:
+
+* Apple Safari on iOS 6.0+
+* Google Chrome on iOS 6.0+
+* Google Chrome on Android 4.0+
+* Default Browser on Android 2.3+
+* Opera Mobile 12.0+
+
+### Supported features
+For a detailed overview of the features supported by each browser version, please have a look at the [Extended browser support information](https://github.com/blueimp/jQuery-File-Upload/wiki/Browser-support).
+
+## Contributing
+**Bug fixes** and **new features** can be proposed using [pull requests](https://github.com/blueimp/jQuery-File-Upload/pulls).
+Please read the [contribution guidelines](https://github.com/blueimp/jQuery-File-Upload/blob/master/CONTRIBUTING.md) before submitting a pull request.
+
+## Support
+This project is actively maintained, but there is no official support channel.
+If you have a question that another developer might help you with, please post to [Stack Overflow](http://stackoverflow.com/questions/tagged/blueimp+jquery+file-upload) and tag your question with `blueimp jquery file upload`.
+
+## License
+Released under the [MIT license](https://opensource.org/licenses/MIT).
diff --git a/apps/files/static/files/jQuery-File-Upload-9.21.0/angularjs.html b/apps/files/static/files/jQuery-File-Upload-9.21.0/angularjs.html
new file mode 100644
index 0000000..2051bbf
--- /dev/null
+++ b/apps/files/static/files/jQuery-File-Upload-9.21.0/angularjs.html
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+",
+
+ options: {
+ classes: {},
+ disabled: false,
+
+ // Callbacks
+ create: null
+ },
+
+ _createWidget: function( options, element ) {
+ element = $( element || this.defaultElement || this )[ 0 ];
+ this.element = $( element );
+ this.uuid = widgetUuid++;
+ this.eventNamespace = "." + this.widgetName + this.uuid;
+
+ this.bindings = $();
+ this.hoverable = $();
+ this.focusable = $();
+ this.classesElementLookup = {};
+
+ if ( element !== this ) {
+ $.data( element, this.widgetFullName, this );
+ this._on( true, this.element, {
+ remove: function( event ) {
+ if ( event.target === element ) {
+ this.destroy();
+ }
+ }
+ } );
+ this.document = $( element.style ?
+
+ // Element within the document
+ element.ownerDocument :
+
+ // Element is window or document
+ element.document || element );
+ this.window = $( this.document[ 0 ].defaultView || this.document[ 0 ].parentWindow );
+ }
+
+ this.options = $.widget.extend( {},
+ this.options,
+ this._getCreateOptions(),
+ options );
+
+ this._create();
+
+ if ( this.options.disabled ) {
+ this._setOptionDisabled( this.options.disabled );
+ }
+
+ this._trigger( "create", null, this._getCreateEventData() );
+ this._init();
+ },
+
+ _getCreateOptions: function() {
+ return {};
+ },
+
+ _getCreateEventData: $.noop,
+
+ _create: $.noop,
+
+ _init: $.noop,
+
+ destroy: function() {
+ var that = this;
+
+ this._destroy();
+ $.each( this.classesElementLookup, function( key, value ) {
+ that._removeClass( value, key );
+ } );
+
+ // We can probably remove the unbind calls in 2.0
+ // all event bindings should go through this._on()
+ this.element
+ .off( this.eventNamespace )
+ .removeData( this.widgetFullName );
+ this.widget()
+ .off( this.eventNamespace )
+ .removeAttr( "aria-disabled" );
+
+ // Clean up events and states
+ this.bindings.off( this.eventNamespace );
+ },
+
+ _destroy: $.noop,
+
+ widget: function() {
+ return this.element;
+ },
+
+ option: function( key, value ) {
+ var options = key;
+ var parts;
+ var curOption;
+ var i;
+
+ if ( arguments.length === 0 ) {
+
+ // Don't return a reference to the internal hash
+ return $.widget.extend( {}, this.options );
+ }
+
+ if ( typeof key === "string" ) {
+
+ // Handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } }
+ options = {};
+ parts = key.split( "." );
+ key = parts.shift();
+ if ( parts.length ) {
+ curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] );
+ for ( i = 0; i < parts.length - 1; i++ ) {
+ curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {};
+ curOption = curOption[ parts[ i ] ];
+ }
+ key = parts.pop();
+ if ( arguments.length === 1 ) {
+ return curOption[ key ] === undefined ? null : curOption[ key ];
+ }
+ curOption[ key ] = value;
+ } else {
+ if ( arguments.length === 1 ) {
+ return this.options[ key ] === undefined ? null : this.options[ key ];
+ }
+ options[ key ] = value;
+ }
+ }
+
+ this._setOptions( options );
+
+ return this;
+ },
+
+ _setOptions: function( options ) {
+ var key;
+
+ for ( key in options ) {
+ this._setOption( key, options[ key ] );
+ }
+
+ return this;
+ },
+
+ _setOption: function( key, value ) {
+ if ( key === "classes" ) {
+ this._setOptionClasses( value );
+ }
+
+ this.options[ key ] = value;
+
+ if ( key === "disabled" ) {
+ this._setOptionDisabled( value );
+ }
+
+ return this;
+ },
+
+ _setOptionClasses: function( value ) {
+ var classKey, elements, currentElements;
+
+ for ( classKey in value ) {
+ currentElements = this.classesElementLookup[ classKey ];
+ if ( value[ classKey ] === this.options.classes[ classKey ] ||
+ !currentElements ||
+ !currentElements.length ) {
+ continue;
+ }
+
+ // We are doing this to create a new jQuery object because the _removeClass() call
+ // on the next line is going to destroy the reference to the current elements being
+ // tracked. We need to save a copy of this collection so that we can add the new classes
+ // below.
+ elements = $( currentElements.get() );
+ this._removeClass( currentElements, classKey );
+
+ // We don't use _addClass() here, because that uses this.options.classes
+ // for generating the string of classes. We want to use the value passed in from
+ // _setOption(), this is the new value of the classes option which was passed to
+ // _setOption(). We pass this value directly to _classes().
+ elements.addClass( this._classes( {
+ element: elements,
+ keys: classKey,
+ classes: value,
+ add: true
+ } ) );
+ }
+ },
+
+ _setOptionDisabled: function( value ) {
+ this._toggleClass( this.widget(), this.widgetFullName + "-disabled", null, !!value );
+
+ // If the widget is becoming disabled, then nothing is interactive
+ if ( value ) {
+ this._removeClass( this.hoverable, null, "ui-state-hover" );
+ this._removeClass( this.focusable, null, "ui-state-focus" );
+ }
+ },
+
+ enable: function() {
+ return this._setOptions( { disabled: false } );
+ },
+
+ disable: function() {
+ return this._setOptions( { disabled: true } );
+ },
+
+ _classes: function( options ) {
+ var full = [];
+ var that = this;
+
+ options = $.extend( {
+ element: this.element,
+ classes: this.options.classes || {}
+ }, options );
+
+ function processClassString( classes, checkOption ) {
+ var current, i;
+ for ( i = 0; i < classes.length; i++ ) {
+ current = that.classesElementLookup[ classes[ i ] ] || $();
+ if ( options.add ) {
+ current = $( $.unique( current.get().concat( options.element.get() ) ) );
+ } else {
+ current = $( current.not( options.element ).get() );
+ }
+ that.classesElementLookup[ classes[ i ] ] = current;
+ full.push( classes[ i ] );
+ if ( checkOption && options.classes[ classes[ i ] ] ) {
+ full.push( options.classes[ classes[ i ] ] );
+ }
+ }
+ }
+
+ this._on( options.element, {
+ "remove": "_untrackClassesElement"
+ } );
+
+ if ( options.keys ) {
+ processClassString( options.keys.match( /\S+/g ) || [], true );
+ }
+ if ( options.extra ) {
+ processClassString( options.extra.match( /\S+/g ) || [] );
+ }
+
+ return full.join( " " );
+ },
+
+ _untrackClassesElement: function( event ) {
+ var that = this;
+ $.each( that.classesElementLookup, function( key, value ) {
+ if ( $.inArray( event.target, value ) !== -1 ) {
+ that.classesElementLookup[ key ] = $( value.not( event.target ).get() );
+ }
+ } );
+ },
+
+ _removeClass: function( element, keys, extra ) {
+ return this._toggleClass( element, keys, extra, false );
+ },
+
+ _addClass: function( element, keys, extra ) {
+ return this._toggleClass( element, keys, extra, true );
+ },
+
+ _toggleClass: function( element, keys, extra, add ) {
+ add = ( typeof add === "boolean" ) ? add : extra;
+ var shift = ( typeof element === "string" || element === null ),
+ options = {
+ extra: shift ? keys : extra,
+ keys: shift ? element : keys,
+ element: shift ? this.element : element,
+ add: add
+ };
+ options.element.toggleClass( this._classes( options ), add );
+ return this;
+ },
+
+ _on: function( suppressDisabledCheck, element, handlers ) {
+ var delegateElement;
+ var instance = this;
+
+ // No suppressDisabledCheck flag, shuffle arguments
+ if ( typeof suppressDisabledCheck !== "boolean" ) {
+ handlers = element;
+ element = suppressDisabledCheck;
+ suppressDisabledCheck = false;
+ }
+
+ // No element argument, shuffle and use this.element
+ if ( !handlers ) {
+ handlers = element;
+ element = this.element;
+ delegateElement = this.widget();
+ } else {
+ element = delegateElement = $( element );
+ this.bindings = this.bindings.add( element );
+ }
+
+ $.each( handlers, function( event, handler ) {
+ function handlerProxy() {
+
+ // Allow widgets to customize the disabled handling
+ // - disabled as an array instead of boolean
+ // - disabled class as method for disabling individual parts
+ if ( !suppressDisabledCheck &&
+ ( instance.options.disabled === true ||
+ $( this ).hasClass( "ui-state-disabled" ) ) ) {
+ return;
+ }
+ return ( typeof handler === "string" ? instance[ handler ] : handler )
+ .apply( instance, arguments );
+ }
+
+ // Copy the guid so direct unbinding works
+ if ( typeof handler !== "string" ) {
+ handlerProxy.guid = handler.guid =
+ handler.guid || handlerProxy.guid || $.guid++;
+ }
+
+ var match = event.match( /^([\w:-]*)\s*(.*)$/ );
+ var eventName = match[ 1 ] + instance.eventNamespace;
+ var selector = match[ 2 ];
+
+ if ( selector ) {
+ delegateElement.on( eventName, selector, handlerProxy );
+ } else {
+ element.on( eventName, handlerProxy );
+ }
+ } );
+ },
+
+ _off: function( element, eventName ) {
+ eventName = ( eventName || "" ).split( " " ).join( this.eventNamespace + " " ) +
+ this.eventNamespace;
+ element.off( eventName ).off( eventName );
+
+ // Clear the stack to avoid memory leaks (#10056)
+ this.bindings = $( this.bindings.not( element ).get() );
+ this.focusable = $( this.focusable.not( element ).get() );
+ this.hoverable = $( this.hoverable.not( element ).get() );
+ },
+
+ _delay: function( handler, delay ) {
+ function handlerProxy() {
+ return ( typeof handler === "string" ? instance[ handler ] : handler )
+ .apply( instance, arguments );
+ }
+ var instance = this;
+ return setTimeout( handlerProxy, delay || 0 );
+ },
+
+ _hoverable: function( element ) {
+ this.hoverable = this.hoverable.add( element );
+ this._on( element, {
+ mouseenter: function( event ) {
+ this._addClass( $( event.currentTarget ), null, "ui-state-hover" );
+ },
+ mouseleave: function( event ) {
+ this._removeClass( $( event.currentTarget ), null, "ui-state-hover" );
+ }
+ } );
+ },
+
+ _focusable: function( element ) {
+ this.focusable = this.focusable.add( element );
+ this._on( element, {
+ focusin: function( event ) {
+ this._addClass( $( event.currentTarget ), null, "ui-state-focus" );
+ },
+ focusout: function( event ) {
+ this._removeClass( $( event.currentTarget ), null, "ui-state-focus" );
+ }
+ } );
+ },
+
+ _trigger: function( type, event, data ) {
+ var prop, orig;
+ var callback = this.options[ type ];
+
+ data = data || {};
+ event = $.Event( event );
+ event.type = ( type === this.widgetEventPrefix ?
+ type :
+ this.widgetEventPrefix + type ).toLowerCase();
+
+ // The original event may come from any element
+ // so we need to reset the target on the new event
+ event.target = this.element[ 0 ];
+
+ // Copy original event properties over to the new event
+ orig = event.originalEvent;
+ if ( orig ) {
+ for ( prop in orig ) {
+ if ( !( prop in event ) ) {
+ event[ prop ] = orig[ prop ];
+ }
+ }
+ }
+
+ this.element.trigger( event, data );
+ return !( $.isFunction( callback ) &&
+ callback.apply( this.element[ 0 ], [ event ].concat( data ) ) === false ||
+ event.isDefaultPrevented() );
+ }
+ };
+
+ $.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) {
+ $.Widget.prototype[ "_" + method ] = function( element, options, callback ) {
+ if ( typeof options === "string" ) {
+ options = { effect: options };
+ }
+
+ var hasOptions;
+ var effectName = !options ?
+ method :
+ options === true || typeof options === "number" ?
+ defaultEffect :
+ options.effect || defaultEffect;
+
+ options = options || {};
+ if ( typeof options === "number" ) {
+ options = { duration: options };
+ }
+
+ hasOptions = !$.isEmptyObject( options );
+ options.complete = callback;
+
+ if ( options.delay ) {
+ element.delay( options.delay );
+ }
+
+ if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) {
+ element[ method ]( options );
+ } else if ( effectName !== method && element[ effectName ] ) {
+ element[ effectName ]( options.duration, options.easing, callback );
+ } else {
+ element.queue( function( next ) {
+ $( this )[ method ]();
+ if ( callback ) {
+ callback.call( element[ 0 ] );
+ }
+ next();
+ } );
+ }
+ };
+ } );
+
+ var widget = $.widget;
+
+
+
+
+}));
diff --git a/apps/files/static/files/upload.js b/apps/files/static/files/upload.js
new file mode 100644
index 0000000..13e5ebc
--- /dev/null
+++ b/apps/files/static/files/upload.js
@@ -0,0 +1,199 @@
+class MultipleFilesWidget {
+ constructor(div) {
+ this.root = div
+ this.fieldName = this.root.getAttribute('data-field')
+ this.fileList = []
+ this.element = {
+ list: this.root.querySelector('.filewidget--list'),
+ uploadButton: this.root.querySelector('.filewidget--input'),
+ field: document.querySelector(`[name="${this.fieldName}"]`)
+ }
+
+ const self = this
+
+ // Set up jquery-fileupload
+ $(this.element.uploadButton).fileupload({
+ dataType: 'json',
+ paramName: 'file',
+ done: function (e, data) {
+ // process server response
+ if (!data.result.is_valid) {
+ throw new Error('Server sent us invalid data!')
+ }
+
+ self.fileFinished.bind(self)(data.result)
+ },
+ add: function (e, data) {
+ $.each(data.files, (index, file) => {
+ self.addFile.bind(self)(null, file.name, self.setFileInProgress.bind(self))
+ });
+
+ data.process().done(function () {
+ data.submit()
+ })
+ }
+ })
+
+ // Set up listening for restore
+ this.element.field.addEventListener('change', evt => {
+ let idList = evt.srcElement.value.split(",")
+ for (let id of idList) {
+ this.addFile(id, id, this.setFileDone.bind(this))
+ }
+ this.viewFileList()
+ })
+ }
+
+ addFile(id, name, stateFunc) {
+ console.log('addfile here!')
+
+ let li = document.createElement('li')
+ li.className = 'filewidget--file'
+ li.innerHTML =
+ `
+
`
+
+ let file = {
+ root: li,
+ element: {
+ icon: li.querySelector('.filewidget--file--icon'),
+ name: li.querySelector('.filewidget--file--name'),
+ actions: li.querySelector('.filewidget--file--actions'),
+ },
+ name: name,
+ id: id || null,
+ }
+
+ stateFunc(file)
+
+ this.fileList.push(file)
+ this.viewFileList()
+ }
+
+ removeFile(file) {
+ // Remove file from display
+ this.element.list.removeChild(file.root)
+
+ // Remove file from fileList
+ this.fileList = this.fileList.filter(cmpFile => cmpFile === file)
+ }
+
+ fileFinished(serverResponse) {
+ // Using data, find the file
+ let file = this.fileList.filter(file => file.name === serverResponse.name)[0]
+
+ // Set the ID
+ file.id = serverResponse.id
+
+ // Set the file state now it's finished
+ this.setFileDone(file)
+ this.viewFileList()
+
+ // Do this last tho
+ this.updateFormField()
+ }
+
+ //
+ // Update the field that keeps track of file IDs
+ //
+
+ updateFormField() {
+ console.log("updateFormField", this)
+
+ let oldVal = this.element.field.value
+ this.element.field.value = this.fileList.filter(f => f.id != null)
+ .map(f => f.id)
+ .toString()
+
+ // Mark form as dirty
+ if (this.element.field.value !== oldVal) {
+ document.getElementById('case-study-form').dispatchEvent(new Event('dirty'))
+ }
+ }
+
+ //
+ // Manage the state of individual files in the widget
+ //
+
+ setFileState(file, state) {
+ file.element.icon.classList = `filewidget--file--icon ${state.iconClassList}`
+ file.element.name.innerText = state.fileName
+ file.element.actions.innerHTML = state.actions || ''
+ }
+
+ setFileInProgress(file) {
+ this.setFileState(file, {
+ iconClassList: 'fa fa-spinner fa-spin',
+ fileName: `${file.name} (uploading...)`
+ })
+ }
+
+ setFileDeletingInProgress(file) {
+ this.setFileState(file, {
+ iconClassList: 'fa fa-spinner fa-spin',
+ fileName: `Deleting...`
+ })
+ }
+
+ setFileDeletingFailed(file) {
+ this.setFileState(file, {
+ iconClassList: 'fa fa-exclamation-triangle',
+ fileName: `${file.name} (delete failed!)`
+ })
+ }
+
+ setFileDone(file) {
+ console.log("setFileDone", file)
+
+ this.setFileState(file, {
+ iconClassList: 'fa fa-file-o',
+ fileName: `${file.name}`,
+ actions: '
'
+ })
+
+ file.element.actions.querySelector('a').addEventListener('click', () => {
+ this.setFileDeletingInProgress(file)
+
+ return fetch(`/files/delete/${file.id}/`, {
+ method: 'POST',
+ credentials: "same-origin",
+ headers: {
+ "X-CSRFToken": jQuery("[name=csrfmiddlewaretoken]").val(),
+ }
+ }).then(response => {
+ if (response.ok) {
+ this.removeFile(file)
+ } else {
+ this.setFileDeletingFailed(file)
+ }
+ })
+ })
+ }
+
+ //
+ // Redraw the file list
+ //
+
+ viewFileList() {
+ console.log("viewFileList", this)
+
+ for (let file of this.fileList) {
+ // Check if it's appended to the list
+ if (!this.element.list.contains(file.root)) {
+ this.element.list.appendChild(file.root)
+ }
+ }
+ }
+}
+
+$(function() {
+ window.official_project_documents = new MultipleFilesWidget(
+ document.querySelector('[data-field=official_project_documents_files]')
+ )
+ window.other_documents = new MultipleFilesWidget(
+ document.querySelector('[data-field=other_documents_files]')
+ )
+ window.shapefiles = new MultipleFilesWidget(
+ document.querySelector('[data-field=shapefiles_files]')
+ )
+})
diff --git a/apps/files/tests.py b/apps/files/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/apps/files/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/apps/files/urls.py b/apps/files/urls.py
new file mode 100644
index 0000000..efae6c9
--- /dev/null
+++ b/apps/files/urls.py
@@ -0,0 +1,10 @@
+from django.conf.urls import url
+
+from .views import FileUploadView, FileDeleteView
+
+app_name = 'files'
+
+urlpatterns = [
+ url(r'^upload/$', FileUploadView.as_view(), name='upload'),
+ url(r'^delete/(?P
\d+)/$', FileDeleteView.as_view(), name='delete'),
+]
diff --git a/apps/files/views.py b/apps/files/views.py
new file mode 100644
index 0000000..b1b38b0
--- /dev/null
+++ b/apps/files/views.py
@@ -0,0 +1,45 @@
+from django.core.exceptions import PermissionDenied
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import JsonResponse
+from django.shortcuts import render
+from django.views.generic import FormView, DetailView
+
+from .forms import FileForm
+from .models import File
+
+class FileUploadView(LoginRequiredMixin, FormView):
+ model = File
+ form_class = FileForm
+
+ def form_valid(self, form):
+ self.object = form.save(commit=False)
+ self.object.user = self.request.user
+ self.object.save()
+
+ return JsonResponse({
+ 'is_valid': True, 'url': self.object.file.url,
+ 'name': self.object.file.name,
+ 'id': self.object.pk
+ })
+
+ def form_invalid(self, form):
+ return JsonResponse({'is_valid': False, 'errors': form.errors})
+
+
+class FileDeleteView(LoginRequiredMixin, DetailView):
+ model = File
+
+ def get(self, request, *args, **kwargs):
+ return self.post(request, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ self.object = self.get_object()
+
+ if request.user != self.object.user:
+ raise PermissionDenied
+
+ self.object.delete()
+
+ return JsonResponse({
+ 'success': True
+ })
diff --git a/apps/map/forms.py b/apps/map/forms.py
index 7904303..0338948 100644
--- a/apps/map/forms.py
+++ b/apps/map/forms.py
@@ -1,5 +1,5 @@
-from django.urls import reverse
from django import forms
+from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.safestring import mark_safe
@@ -9,7 +9,10 @@ from crispy_forms.bootstrap import Tab, TabHolder, PrependedText, FormActions
from dal import autocomplete
from leaflet.forms.widgets import LeafletWidget
+from apps.files.models import File
+
from .models import CaseStudy, SpatialRefSys
+from .widgets import CommaSeparatedTextInput
class MinimumZoomWidget(LeafletWidget):
@@ -18,6 +21,7 @@ class MinimumZoomWidget(LeafletWidget):
class BaseCaseStudyForm(forms.models.ModelForm):
"""Base form class for the CaseStudy model."""
+
def __init__(self, *args, **kwargs):
super(BaseCaseStudyForm, self).__init__(*args, **kwargs)
self.helper = FormHelper(self)
@@ -27,6 +31,7 @@ class BaseCaseStudyForm(forms.models.ModelForm):
self.helper.form_action = 'add'
self.helper.label_class = 'col-lg-2'
self.helper.field_class = 'col-lg-8'
+ self.helper.include_media = False
class Meta:
model = CaseStudy
@@ -37,9 +42,6 @@ class BaseCaseStudyForm(forms.models.ModelForm):
'SCALE': False
}
}),
- 'official_project_documents': forms.ClearableFileInput(attrs={'multiple': True}),
- 'other_documents': forms.ClearableFileInput(attrs={'multiple': True}),
- 'shapefiles': forms.ClearableFileInput(attrs={'multiple': True}),
}
@@ -79,9 +81,52 @@ class ShortCaseStudyForm(BaseCaseStudyForm):
]
+class BootstrapClearableFileInput(forms.ClearableFileInput):
+ template_name = 'map/forms/widgets/file.html'
+
+
class LongCaseStudyForm(BaseCaseStudyForm):
"""Long version of the CaseStudy form."""
+ official_project_documents = forms.FileField(
+ widget=BootstrapClearableFileInput(attrs={
+ 'url': reverse_lazy('files:upload'),
+ 'field': 'official_project_documents_files',
+ }), required=False
+ )
+
+ official_project_documents_files = forms.ModelMultipleChoiceField(
+ queryset=File.objects.all(),
+ widget=CommaSeparatedTextInput(),
+ required=False
+ )
+
+ other_documents = forms.FileField(
+ widget=BootstrapClearableFileInput(attrs={
+ 'url': reverse_lazy('files:upload'),
+ 'field': 'other_documents_files',
+ }), required=False
+ )
+
+ other_documents_files = forms.ModelMultipleChoiceField(
+ queryset=File.objects.all(),
+ widget=CommaSeparatedTextInput(),
+ required=False
+ )
+
+ shapefiles = forms.FileField(
+ widget=BootstrapClearableFileInput(attrs={
+ 'url': reverse_lazy('files:upload'),
+ 'field': 'shapefiles_files',
+ }), required=False
+ )
+
+ shapefiles_files = forms.ModelMultipleChoiceField(
+ queryset=File.objects.all(),
+ widget=CommaSeparatedTextInput(),
+ required=False
+ )
+
coordinate_reference_system = forms.ModelChoiceField(
queryset=SpatialRefSys.objects.all(),
widget=autocomplete.ModelSelect2(url='srs-autocomplete'),
@@ -126,7 +171,7 @@ class LongCaseStudyForm(BaseCaseStudyForm):
POSITIVE_CASE_TYPE_CHOICES = [
(choice[0], mark_safe('%s %s ' % (choice[1], self.POSITIVE_CASE_TYPE_HELP[choice[0]])))
- for choice in CaseStudy.POSITIVE_CASE_TYPE_CHOICES
+ for choice in CaseStudy.POSITIVE_CASE_TYPE_CHOICES
]
self.fields['positive_case_type'] = forms.ChoiceField(
@@ -275,8 +320,11 @@ class LongCaseStudyForm(BaseCaseStudyForm):
Tab(
_("Uploads"),
'official_project_documents',
+ 'official_project_documents_files',
'other_documents',
+ 'other_documents_files',
'shapefiles',
+ 'shapefiles_files',
'coordinate_reference_system',
'name_of_territory_or_area',
'shown_on_other_platforms',
@@ -289,3 +337,11 @@ class LongCaseStudyForm(BaseCaseStudyForm):
class Meta(BaseCaseStudyForm.Meta):
exclude = ('approved',)
+
+ class Media:
+ js = (
+ 'files/jquery.ui.widget.js',
+ 'files/jquery.iframe-transport.js',
+ 'files/jquery.fileupload.js',
+ 'files/upload.js',
+ )
diff --git a/apps/map/migrations/0056_delete_shapefile.py b/apps/map/migrations/0056_delete_shapefile.py
new file mode 100644
index 0000000..99f4ddd
--- /dev/null
+++ b/apps/map/migrations/0056_delete_shapefile.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-04-19 17:32
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('map', '0055_auto_20180419_1650'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='Shapefile',
+ ),
+ ]
diff --git a/apps/map/migrations/0057_auto_20180423_0220.py b/apps/map/migrations/0057_auto_20180423_0220.py
new file mode 100644
index 0000000..f2fdcf2
--- /dev/null
+++ b/apps/map/migrations/0057_auto_20180423_0220.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-04-23 02:20
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('files', '0001_initial'),
+ ('map', '0056_delete_shapefile'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='casestudy',
+ name='official_project_documents',
+ ),
+ migrations.AddField(
+ model_name='casestudy',
+ name='official_project_documents',
+ field=models.ManyToManyField(blank=True, help_text='Attach any legal or official documents that relate to the project.', null=True, related_name='official_project_document_for', to='files.File', verbose_name='Official project documents'),
+ ),
+ migrations.RemoveField(
+ model_name='casestudy',
+ name='other_documents',
+ ),
+ migrations.AddField(
+ model_name='casestudy',
+ name='other_documents',
+ field=models.ManyToManyField(blank=True, help_text='Attach any other documents that relate to the project.', null=True, related_name='other_document_for', to='files.File', verbose_name='Other documents'),
+ ),
+ migrations.RemoveField(
+ model_name='casestudy',
+ name='shapefiles',
+ ),
+ migrations.AddField(
+ model_name='casestudy',
+ name='shapefiles',
+ field=models.ManyToManyField(blank=True, help_text='If you have territory that you would like to show in relation to this project - e.g. Bienes Comunales de Ixtepec etc. This is a set of 3 or more (often 5-6) files with file extensions like .cpg, .dbf, .prj, .qpj, .shp, .shx', null=True, related_name='shapefile_for', to='files.File', verbose_name='Shapefiles'),
+ ),
+ ]
diff --git a/apps/map/migrations/0058_auto_20180429_2205.py b/apps/map/migrations/0058_auto_20180429_2205.py
new file mode 100644
index 0000000..c85401b
--- /dev/null
+++ b/apps/map/migrations/0058_auto_20180429_2205.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-04-29 22:05
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('map', '0057_auto_20180423_0220'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='casestudy',
+ name='official_project_documents',
+ field=models.ManyToManyField(blank=True, help_text='Attach any legal or official documents that relate to the project.', related_name='official_project_document_for', to='files.File', verbose_name='Official project documents'),
+ ),
+ migrations.AlterField(
+ model_name='casestudy',
+ name='other_documents',
+ field=models.ManyToManyField(blank=True, help_text='Attach any other documents that relate to the project.', related_name='other_document_for', to='files.File', verbose_name='Other documents'),
+ ),
+ migrations.AlterField(
+ model_name='casestudy',
+ name='shapefiles',
+ field=models.ManyToManyField(blank=True, help_text='If you have territory that you would like to show in relation to this project - e.g. Bienes Comunales de Ixtepec etc. This is a set of 3 or more (often 5-6) files with file extensions like .cpg, .dbf, .prj, .qpj, .shp, .shx', related_name='shapefile_for', to='files.File', verbose_name='Shapefiles'),
+ ),
+ ]
diff --git a/apps/map/models.py b/apps/map/models.py
index 3f63e60..aa0d1ec 100644
--- a/apps/map/models.py
+++ b/apps/map/models.py
@@ -17,8 +17,7 @@ from . import validators
class CaseStudyDraft(models.Model):
author = models.ForeignKey(
- User,
- on_delete=models.CASCADE
+ User, on_delete=models.CASCADE
)
data = models.TextField()
@@ -36,12 +35,6 @@ class SpatialRefSys(connection.ops.spatial_ref_sys()):
verbose_name = "spatial reference system"
-class Shapefile(models.Model):
- file = models.FileField(
- upload_to='shapefiles/',
- )
-
-
class CaseStudyQuerySet(models.QuerySet):
def approved(self):
return self.filter(
@@ -1007,31 +1000,31 @@ class CaseStudy(models.Model):
##
# 4.1
- official_project_documents = models.FileField(
+ official_project_documents = models.ManyToManyField(
+ 'files.File',
+ related_name='official_project_document_for',
verbose_name=_("Official project documents"),
help_text=_("Attach any legal or official documents that relate to the project."),
- default=None,
- null=True,
blank=True,
)
# 4.2
- other_documents = models.FileField(
+ other_documents = models.ManyToManyField(
+ 'files.File',
+ related_name='other_document_for',
verbose_name=_("Other documents"),
help_text=_("Attach any other documents that relate to the project."),
- default=None,
- null=True,
blank=True,
)
# 4.3.1
- shapefiles = models.FileField(
+ shapefiles = models.ManyToManyField(
+ 'files.File',
+ related_name='shapefile_for',
verbose_name=_("Shapefiles"),
help_text=_("If you have territory that you would like to show in relation to this project - e.g. Bienes \
Comunales de Ixtepec etc. This is a set of 3 or more (often 5-6) files with file extensions like \
.cpg, .dbf, .prj, .qpj, .shp, .shx"),
- default=None,
- null=True,
blank=True
)
@@ -1084,7 +1077,6 @@ class CaseStudy(models.Model):
# Continue normal save method by calling original save method.
super(CaseStudy, self).save(*args, **kwargs)
-
def is_video_youtube(self):
return self.video.count("youtube.com") > 0
@@ -1092,7 +1084,6 @@ class CaseStudy(models.Model):
"""Gets the 11 character YouTube video ID from the video field."""
return parse.parse_qs(parse.urlparse(self.video).query)["v"][0]
-
def is_video_vimeo(self):
return self.video.count("vimeo.com") > 0
@@ -1100,7 +1091,6 @@ class CaseStudy(models.Model):
"""Gets the 11 number video ID from the video field."""
return parse.urlparse(self.video).path
-
def get_negative_case_reasons_no_other(self):
"""Return a list of negative case reasons, minus the 'other' choice (if selected)"""
choices = self.get_negative_case_reasons_list()
diff --git a/apps/map/templates/map/form.html b/apps/map/templates/map/form.html
index 1f9c3ce..f2d1ddd 100644
--- a/apps/map/templates/map/form.html
+++ b/apps/map/templates/map/form.html
@@ -33,6 +33,41 @@
border: 1px solid #ccc;
font-weight: bold;
}
+
+ .filewidget--input { position: absolute; left: -1000px; }
+ .filewidget--list {
+ list-style-type: none;
+ padding-left: 0;
+ margin-left: 0;
+ margin-top: 12px;
+ }
+
+ .filewidget--file {
+ border: 1px solid #aaa;
+ display: inline-block;
+ padding: 4px 8px;
+ width: 34em;
+ background-color: #eee;
+ border-radius: 4px;
+ cursor: default;
+ }
+
+ .filewidget--file--icon {
+ margin-right: 8px;
+ }
+
+ .filewidget--file--actions {
+ float: right;
+ }
+
+ .filewidget--file--actions a {
+ color: black;
+ cursor: pointer;
+ }
+
+ .filewidget--file--actions a:hover {
+ color: red;
+ }
{% endblock %}
@@ -509,10 +544,10 @@ function initDrafts() {
var button = new SaveButton(document.querySelector('.savebutton'))
// Dirty forms set up
- $('#case-study-form').dirtyForms()
+ $('#case-study-form').dirtyForms();
$('#case-study-form').on('dirty.dirtyforms', ev => {
button.switchStateUnsaved()
- })
+ });
// Save button
button.element.button.addEventListener('click', evt => {
diff --git a/apps/map/templates/map/forms/widgets/file.html b/apps/map/templates/map/forms/widgets/file.html
new file mode 100644
index 0000000..d17a136
--- /dev/null
+++ b/apps/map/templates/map/forms/widgets/file.html
@@ -0,0 +1,7 @@
+
diff --git a/apps/map/views.py b/apps/map/views.py
index 005c374..7d7f525 100644
--- a/apps/map/views.py
+++ b/apps/map/views.py
@@ -1,3 +1,5 @@
+import json
+
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.mail import send_mail
@@ -11,6 +13,8 @@ from django.views.generic.edit import CreateView
from dal import autocomplete
+from apps.files.models import File
+
from .models import CaseStudy, CaseStudyDraft, SpatialRefSys
from .forms import ShortCaseStudyForm, LongCaseStudyForm
@@ -56,11 +60,26 @@ class BaseForm(LoginRequiredMixin, CreateView):
)
def form_valid(self, form):
+ form.cleaned_data.pop('official_project_documents', None)
+ form.cleaned_data.pop('other_documents', None)
+ form.cleaned_data.pop('shapefiles', None)
+
self.object = form.save()
+
+ self.object.official_project_documents = form.cleaned_data.get(
+ 'official_project_document_files', []
+ )
+ self.object.other_documents = form.cleaned_data.get(
+ 'other_documents_files', []
+ )
+ self.object.shapefiles = form.cleaned_data.get(
+ 'shapefiles_files', []
+ )
+
self.send_email()
# Delete the corresponding draft
- draft = CaseStudyDraft.objects.get(author=request.user)
+ draft = CaseStudyDraft.objects.get(author=self.request.user)
if draft:
draft.delete()
@@ -100,7 +119,7 @@ class SpatialRefSysAutocomplete(autocomplete.Select2QuerySetView):
return qs
-class Drafts(View):
+class Drafts(LoginRequiredMixin, View):
"""Retrieve or save a draft."""
def get_object(self, request):
@@ -110,9 +129,6 @@ class Drafts(View):
return None
def get(self, request):
- if not request.user.is_authenticated:
- return HttpResponse(status=403) # Forbidden
-
draft = self.get_object(request)
if draft == None:
return HttpResponse(status=404) # Not Found
@@ -120,9 +136,6 @@ class Drafts(View):
return HttpResponse(draft.data, content_type="application/json")
def put(self, request):
- if not request.user.is_authenticated:
- return HttpResponse(status=403) # Forbidden
-
# Find an existing object is there is one
draft = self.get_object(request)
if draft == None:
@@ -136,10 +149,35 @@ class Drafts(View):
return HttpResponse(status=200) # OK
def delete(self, request):
- if not request.user.is_authenticated:
- return HttpResponse(status=403) # Forbidden
-
draft = self.get_object(request)
+
if draft != None:
+ data = json.loads(draft.data)
+
+ for k in ['official_project_documents', 'other_documents',
+ 'shapefiles']:
+
+ items = list(filter(
+ lambda x: (
+ x['name'] == '{0}_files'.format(k)
+ and x['value'] != ''
+ ), data['data']['form']
+ ))
+
+ try:
+ items = items[0]['value'].split(',')
+ except IndexError:
+ continue
+
+ for item in items:
+ try:
+ f = File.objects.get(id=item)
+ if f.user != self.request.user:
+ continue
+ f.delete()
+ except File.DoesNotExist:
+ continue
+
draft.delete()
+
return HttpResponse(status=204)
diff --git a/apps/map/widgets.py b/apps/map/widgets.py
new file mode 100644
index 0000000..4259571
--- /dev/null
+++ b/apps/map/widgets.py
@@ -0,0 +1,21 @@
+from django.forms import widgets
+
+
+class CommaSeparatedTextInput(widgets.HiddenInput):
+ def format_value(self, value):
+ try:
+ value = ','.join(value)
+ except TypeError:
+ value = ''
+ return super().format_value(value)
+
+ def value_from_datadict(self, data, files, name):
+ value = super().value_from_datadict(data, files, name)
+
+ if value == '':
+ return None
+
+ try:
+ return value.split(',')
+ except AttributeError:
+ return None
diff --git a/ojusomap/settings.py b/ojusomap/settings.py
index b8ca7f5..a12c158 100644
--- a/ojusomap/settings.py
+++ b/ojusomap/settings.py
@@ -40,6 +40,7 @@ else:
INSTALLED_APPS = [
'apps.contact',
+ 'apps.files',
'apps.map',
'apps.profiles',
'avatar',
diff --git a/ojusomap/templates/base.html b/ojusomap/templates/base.html
index 8c79a51..8492519 100644
--- a/ojusomap/templates/base.html
+++ b/ojusomap/templates/base.html
@@ -54,87 +54,87 @@
}
{% endblock %}
-
-
-
-
-
-
+
+
+
+
+
-
-
- {% if messages %}
- {% for message in messages %}
-
- {{ message }}
-
- ×
-
-
- {% endfor %}
- {% endif %}
-
- {% block content %}
- {% endblock %}
-
- {% block footer %}
-