git clone --recursive https://github.com/mhep-transition/mhep-django
Or, if you've already cloned mhep-django
, run:
git submodule update --init --recursive
-
Install Vagrant 2.0.1+
-
Install Virtualbox 5.2.18
It should create a new Ubuntu 18.04 VM and configure everything.
With the vagrant box running, run:
vagrant ssh
Once connected to the box, simply run:
make run
This will start the Django server.
Browse to localhost:9090
An administrative interface is available at localhost:9090/admin, the username is localadmin
, password localadmin
Here we document some decisions and principles we worked to while carrying out the port from emoncms.
Previously, most app logic was implemented in Javascript, but there were a number of places where things were rendered by PHP. We almost completely eliminated this so that all data used by the app comes via API endpoints.
The principle was to decide one way or another whether it's a backend (rendered HTML) app or a frontend+API app.
There were 2 notable places we had to use Django template functions:
-
The
{% static %}
template tag. This is used by both HTML files (for imports) and by Javascript (for dynamic loading of scripts). For the 2nd case, we createdurlHelper.static(..)
- a JS function which hides the use of{% static %}
in a single place. -
The
{% url %}
(AKAreverse
) template tag, for deriving the URL of an API endpoint. Again, we createdurlHelper.api.*()
functions to wrap and hide the use of Django's template language.
The data model of the app - the relationships between assessments, users, organisations and libraries - is defined in Django's models, views and permission classes.
The principle was to define this only in the API and try not to duplicate that knowledge in the frontend.
For example: only an organisation admin can add or remove librarians to an organisation.
That's defined in the API. But how to render different UI to the user depending if they're an admin or not?
One approach would be for the Javascript to check if we're an organisation admin, and selectively show the buttons to add and remove librarians.
But that would be duplicating the logic. Instead we chose to implement
permissions
on the organisation list endpoint:
"permissions": {
"can_add_remove_members": true,
"can_promote_demote_librarians": true,
}
This describes what the current user is allowed to do in that organisation - so the frontend code doesn't need to understand the current user's roles directly.
In the absence of a modern framework like React, the app manually manipulates the DOM. This means that code and presentation are mixed up and it can be hard to understand what's going on.
For the bits of frontend we worked on (e.g. libraries manager, organisation manager) we preferred the principle of cloning templates.
Rather than outputting table rows like this:
var myhtml = '<tr>';
myhtml += 'something ';
myhtml += '<button>' x.name + '</button';
myhtml += '</tr>';
... we'd define a hidden template row in the HTML file:
<tr id="my-row-template" style: "display: none">
something <button class="foo-button"></button>
</tr>
... then clone and manipulate it from Javascript:
var newRow = $('#my-row-template').clone();
newRow.removeAttr('id');
newRow.find('.foo-button').html(x.name);
newRow.show()
This way means you can look at the HTML and more easily understand how the DOM is supposed to look.
The app is fully versioned at the Django application level, with names like v1
, v2
etc.
Different app versions live under a URL prefix e.g. /v1/
.
App versions are highly isolated, meaning each version has its own:
- templates & static assets
- URL schema (including API URLs)
- database models
To start working on a new version of the app, cd into ./mhep
and run the script
upversion.sh
and set the new version as dev
.
The script copies an app version to a new version, for example, going from v1
to dev
:
- copy-pastes the whole directory
mhep/mhep/v1
to/mhep/mhep/dev
- renames the
static/v1
andtemplates/v1
subdirectories - adds the new
dev
app to Django'sLOCAL_APPS
setting andurls.py
- modifies the
dev/fixtures/*.json
files with the updated app label
When the dev
app is finished it should be renamed to e.g. v2
.
Once an app is assigned a version number its code should never be modified again.
- List assessments
- List assessments for organisation
- Get assessment
- Create assessment
- Create assessment for organisation
- Update a field on assessment
- Delete assessment
- List users
- List organisations
- Add member to organisation
- Remove member from organisation
- Set organisation user as librarian
- Unset organisation user as librarian
- List libraries
- Create a library
- Create a library for organisation
- Update a library
- Share an organisation library with another organisation
- Unshare an organisation library with another organisation
- List organisations a library is shared with
- Delete a library
- Create item in library
- Update item in library
- Delete item in library
All endpoints start with /v1/api
e.g. http://localhost:9090/v1/api/assessments/
.
GET /assessments/
List all assessments the current user has access to.
ℹ️ porting notes: replaces previous assessment/list
route.
GET /assessments/
Returns:
HTTP 200 OK
Content-Type: application/json
[
{
"id": "1",
"name": "Example assessment",
"description": "Example description",
"openbem_version": "10.1.1",
"status": "In progress",
"created_at": "2019-08-15T15:25:37.634182Z",
"updated_at": "2019-08-21T10:40:58.830425Z",
"author": "localadmin",
"userid": "1",
"mdate": "1566384058",
}
]
GET /organisations/:id/assessments/
List all assessments that belong to an organisation.
ℹ️ porting notes: replaces previous assessment/list
with orgid
param.
GET /organisations/1/assessments/
Returns:
HTTP 200 OK
Content-Type: application/json
[
{
"id": "1",
"name": "Example assessment",
"description": "Example description",
"openbem_version": "10.1.1",
"status": "In progress",
"created_at": "2019-08-15T15:25:37.634182Z",
"updated_at": "2019-08-21T10:40:58.830425Z",
"author": "localadmin",
"userid": "1",
"mdate": "1566384058",
}
]
GET /assessments/:id/
ℹ️ porting notes: replaces previous route assessment/get
> curl http://localhost:9090/v1/api/assessments/1
Returns:
HTTP 200 OK
Content-Type: application/json
{
"id": "1",
"name": "Example assessment",
"description": "Example description",
"openbem_version": "10.1.1",
"status": "In progress",
"created_at": "2019-08-15T15:25:37.634182Z",
"updated_at": "2019-08-21T10:40:58.830425Z",
"author": "localadmin",
"userid": "1",
"mdate": "1566384058",
"images": [
{
"id": 7,
"url": "https://gallery.img/6.jpg",
"width": 400,
"height": 300,
"thumbnail_url": "https://gallery.img/6_thumb.jpg",
"thumbnail_width": 200,
"thumbnail_height": 150,
"note": "",
"is_featured": true
}
],
"data": {
"master": {
"scenario_name": "Master",
"household": {
"3a_heatinghours_weekday_on1_hours": 6,
"3a_heatinghours_weekday_on1_mins": 45,
...
},
...
}
}
}
POST /assessments/
ℹ️ porting notes: replaces previous assessment/create
route.
> curl -v \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/assessments/ \
--data @- << EOF
{
"name": "Example assessment",
"description": "Example description",
"openbem_version": "10.1.1"
}
EOF
Returns:
HTTP 201 Created
Content-Type: application/json
{
"id": 6,
"name": "Example assesment",
"description": "Example description",
"openbem_version": "10.1.1",
"status": "In progress",
"created_at": "2019-06-01T16:35:34Z",
"updated_at": "2019-06-01T16:35:34Z",
"mdate": "1559406934",
"author": "janedoe",
"userid": "2",
}
POST /organisations/:id/assessments/
ℹ️ porting notes: replaces previous assessment/create
with org
param.
> curl -v \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/organisations/1/assessments/ \
--data @- << EOF
{
"name": "Example assessment",
"description": "Example description",
"openbem_version": "10.1.1"
}
EOF
Returns:
HTTP 201 Created
Content-Type: application/json
{
"id": 6,
"name": "Example assesment",
"description": "Example description",
"openbem_version": "10.1.1",
"status": "In progress",
"created_at": "2019-06-01T16:35:34Z",
"updated_at": "2019-06-01T16:35:34Z",
"mdate": "1559406934",
"author": "janedoe",
"userid": "2",
}
PATCH /assessments/:id/
Content-Type: application/json
ℹ️ porting notes: replaces previous routes:
assessment/setdata
assessment/setnameanddescription
assessment/setopenBEMversion
assessment/setstatus
> curl -v \
-X PATCH \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/assessments/1/ \
--data @- << EOF
{
"data": {
"master": {
"scenario_name": "Master",
"household": {
"3a_heatinghours_weekday_on1_hours": 6,
"3a_heatinghours_weekday_on1_mins": 45,
...
}
}
Returns:
HTTP 204 No content
> curl -v \
-X PATCH \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/assessments/1/ \
--data @- << EOF
{
"status": "Complete",
}
EOF
DELETE /assessments/:id/
ℹ️ porting notes: replaces previous assessment/delete
route.
> curl -v \
-X DELETE \
http://localhost:9090/v1/api/assessments/1/
Returns:
HTTP 204 No content
## Upload an image to the image gallery
POST /assessments/:id/images/
curl -v \
-F 'file=@image.png' \
http://localhost:9090/dev/api/assessments/1/images/
Returns:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 3,
"url": "/media/images/342e8902-b709-4fff-b6da-73acc0c9488d.png",
"width": 800,
"height": 127,
"thumbnail_url": "/media/images/342e8902-b709-4fff-b6da-73acc0c9488d_thumb.jpg",
"thumbnail_width": 600,
"thumbnail_height": 95,
"note": "image",
"is_featured": false
}
## Changing the featured image
POST /assessments/:id/images/featured/
> curl -v \
-X POST \
-H "Content-Type: application/json" \
http://localhost:9090/dev/api/assessments/1/images/featured/ \
--data @- << EOF
{
"id": 6
}
EOF
Returns:
HTTP/1.1 204 No Content
PATCH /images/:id/
> curl -v \
-X PATCH \
-H "Content-Type: application/json" \
http://localhost:9090/dev/api/images/10/ \
--data @- << EOF
{
"note": "Corbyn's greenhouse"
}
EOF
Returns:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 10,
"note": "Corbyn's greenhouse",
... # All other fields the same
}
## Delete an image
DELETE /images/:id/
curl -v \
-X DELETE \
http://localhost:9090/dev/api/images/6/
Returns:
HTTP/1.1 204 No Content
GET /users/
List all the users.
GET /users/
Returns:
HTTP 200 OK
Content-Type: application/json
[
{
"id": "1",
"name": "admin"
},
{
"id": "2",
"name": "janedoe"
},
{
"id": "3",
"name": "michael2"
}
]
GET /organisations/
List all organisations the current user is a member of. Each organisation also returns permissions
, which shows what the current user can and can not do.
ℹ️ porting notes: replaces previous assessment/getorganisations
route.
GET /organisations/
Returns:
HTTP 200 OK
Content-Type: application/json
[
{
"id": "1",
"name": "Chigley Community Energy",
"assessments": 0,
"members": [
{
"userid": "2",
"name": "janedoe",
"last_active": "2019-06-03T16:35:00+00:00",
"is_admin": true,
"is_librarian": true
}
],
"permissions": {
"can_add_remove_members": true,
"can_promote_demote_librarians": true,
}
},
{
"id": "2",
"name": "Sandford Assessment CIC",
"assessments": 1,
"members": [
{
"userid": "2",
"name": "janedoe",
"last_login": "2019-06-03T16:35:00+00:00",
"is_admin": true,
"is_librarian": false
},
{
"userid": "3",
"name": "michael2",
"last_login": "2019-06-03T16:35:00+00:00"
"is_admin": false,
"is_librarian": true
}
],
"permissions": {
"can_add_remove_members": true,
"can_promote_demote_librarians": true,
}
}
]
POST /organisations/:orgid/members/:userid/
> curl -X POST http://localhost:9090/dev/api/organisations/1/members/3/
Returns:
HTTP 204 No content
DELETE /organisations/:orgid/members/:userid/
> curl -X DELETE http://localhost:9090/dev/api/organisations/1/members/3/
Returns:
HTTP 204 No content
POST /organisations/:orgid/librarians/:userid/
> curl -X POST http://localhost:9090/dev/api/organisation/1/librarians/5/
Returns:
HTTP 204 No content
DELETE /organisations/:orgid/librarians/:userid/
> curl -X DELETE http://localhost:9090/dev/api/organisation/1/librarians/5/
Returns:
HTTP 204 No content
GET /libraries/
List a collection of libraries (and their library items) that is either:
a) a global library b) a library that belongs to me, c) a library belonging to an organisation I'm a member of d) a library that has been shared with an organisation I'm a member of
ℹ️ porting notes: replaces previous route assessment/loaduserlibraries
> curl http://localhost:9090/v1/api/libraries/
Returns:
HTTP 200 OK
Content-Type: application/json
[
{
"id": 1,
"name": "Jane's fabric elements",
"type": "elements",
"data": {
"SWU_01": {
"tags": ["Wall"],
"name": "225mm uninsulated brick wall",
"description": "225mm uninslated solid brick wall, plaster internally",
"location": "",
"source": "Salford University on site monitoring\/ SAP table 1e, p.195",
"uvalue": 1.9,
"kvalue": 135,
"g": 0,
"gL": 0,
"ff": 0
},
"SWU_02": {
"tags": ["Wall"],
"name": "some other type of wall",
"description": "with another description",
"location": "",
"source": "Salford University on site monitoring\/ SAP table 1e, p.195",
"uvalue": 1.9,
"kvalue": 135,
"g": 0,
"gL": 0,
"ff": 0
}
},
"created_at": "2019-11-25T17:34:05.766267Z",
"updated_at": "2019-11-25T17:34:05.766267Z",
"permissions": {
"can_write": true,
"can_share": false
},
"owner": {
"type": "personal",
"id": "1",
"name": "janedoe"
}
},
{
"name": "Jane's fabric element measures",
"type": "draught_proofing_measures",
"items": {
"DP_01": {
"name": "Basic Draught-proofing Measures",
"q50": 12,
"description": "This may include DIY draught-proofing measures to doors...",
"performance": "Dependent on existing. 8-12 ...",
"maintenance": "Minimal. Ensure any draught-proofing strips are replaced..."
},
"DP_02": {
"name": "Another draught proofing measure",
"q50": 12,
"description": "This may include DIY draught-proofing measures to doors...",
"performance": "Dependent on existing. 8-12 ...",
"maintenance": "Minimal. Ensure any draught-proofing strips are replaced..."
}
},
"created_at": "2019-11-25T17:34:05.766267Z",
"updated_at": "2019-11-25T17:34:05.766267Z",
"permissions": {
"can_write": true,
"can_share": false
},
"owner": {
"type": "personal",
"id": "1",
"name": "janedoe"
}
}
]
POST /libraries/
ℹ️ porting notes: replaces previous assessment/newlibrary
route. It can also add data in a
single request, where the previous route required the subsequent use of savelibrary
> curl -v \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/libraries/ \
--data @- << EOF
{
"name": "StandardLibrary - user",
"type": "draught_proofing_measures",
"data": {
"DP_01": {
"name": "Basic Draught-proofing Measures",
"q50": 12,
"description": "This may include DIY draught-proofing measures to doors...",
"performance": "Dependent on existing. 8-12 ...",
"maintenance": "Minimal. Ensure any draught-proofing strips are replaced..."
},
"DP_02": {
"name": "Another draught proofing measure",
"q50": 12,
"description": "This may include DIY draught-proofing measures to doors...",
"performance": "Dependent on existing. 8-12 ...",
"maintenance": "Minimal. Ensure any draught-proofing strips are replaced..."
}
}
Returns:
HTTP 204 No content
POST /organisations/:id/libraries/
> curl -v \
-H "Content-Type: application/json" \
http://localhost:9090/v2/api/organisations/1/libraries/ \
--data @- << EOF
{
"name": "My organisation library",
"type": "draught_proofing_measures",
"data": {
"DP_01": {
"name": "Basic Draught-proofing Measures",
"q50": 12,
"description": "This may include DIY draught-proofing measures to doors...",
"performance": "Dependent on existing. 8-12 ...",
"maintenance": "Minimal. Ensure any draught-proofing strips are replaced..."
},
"DP_02": {
"name": "Another draught proofing measure",
"q50": 12,
"description": "This may include DIY draught-proofing measures to doors...",
"performance": "Dependent on existing. 8-12 ...",
"maintenance": "Minimal. Ensure any draught-proofing strips are replaced..."
}
}
Returns:
HTTP 204 No content
PATCH /libraries/:id/
Content-Type: application/json
ℹ️ porting notes: replaces previous assessment/savelibrary
route.
> curl -v \
-X PATCH \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/libraries/1/ \
--data @- << EOF
{
"data": {},
}
EOF
Returns:
HTTP 204 No content
POST /organisations/:orgid/libraries/:libraryid/shares/:otherorgid/
> curl -v -X POST http://localhost:9090/dev/api/organisation/1/libraries/5/shares/2/ \
DELETE /organisations/:orgid/libraries/:libraryid/shares/:otherorgid/
Returns:
HTTP 204 No content
> curl -v -X DELETE http://localhost:9090/dev/api/organisation/1/libraries/5/shares/2/ \
For a given library that belongs to an organisation, list any organisations the library is shared with.
GET /organisations/:orgid/libraries/:libraryid/shares/
> curl http://localhost:9090/dev/api/organisation/1/libraries/5/shares/ \
Returns:
HTTP 200 OK
Content-Type: application/json
[
{
"id": "1",
"name": "Chigley Community Energy"
},
{
"id": "2",
"name": "Sandford Assessment CIC"
}
]
DELETE /librarys/:id/
ℹ️ porting notes: replaces previous assessment/deletelibrary
route.
> curl -v \
-X DELETE \
http://localhost:9090/v1/api/libraries/1/
Returns:
HTTP 204 No content
POST /libraries/:id/items/
ℹ️ porting notes: replaces previous assessment/additemtolibrary
route.
> curl -v \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/libraries/1/items/ \
--data @- << EOF
{
"tag": "SWIN_04",
"item": {
"name": "100-140mm External Wall Insulation EWI on filled cavity wall.",
"source": "URBED/ SAP table 1e, p.195",
"uvalue": 0.15,
"kvalue": 110,
"tags": ["Wall"]
}
}
EOF
Returns:
HTTP 204 No content
PUT /libraries/:id/items/:tag/
ℹ️ porting notes: replaces previous assessment/edititeminlibrary
route.
> curl -v \
-X PUT \
-H "Content-Type: application/json" \
http://localhost:9090/v1/api/libraries/1/item/SWIN_04/ \
--data @- << EOF
{
"name": "100-140mm External Wall Insulation EWI on filled cavity wall.",
"source": "URBED/ SAP table 1e, p.195",
"uvalue": 0.15,
"kvalue": 110,
"tags": ["Wall"]
}
EOF
Returns:
HTTP 204 No content
DELETE /libraries/:id/items/:tag/
ℹ️ porting notes: replaces previous assessment/deletelibraryitem
route.
> curl -v -X DELETE \
http://localhost:9090/v1/api/libraries/1/item/SWIN_04/
Returns:
HTTP 204 No content