Compare commits

...

13 commits

Author SHA1 Message Date
Rupika Dikkala
ee9d181d3f
Merge pull request #134 from danieldupriest/frontend-comments
Frontend comments
2019-03-24 09:57:49 -07:00
Rupika Dikkala
2876ccf4d8
Merge pull request #136 from danieldupriest/readme-additions
Added Testing and Policy file info to README.md. Also, removed test users from database.
2019-03-24 09:57:09 -07:00
Daniel Dupriest
91bb283919
More description. 2019-03-23 16:08:33 -07:00
Daniel Dupriest
0a33dcc6c4
More line wrapping fixes. 2019-03-23 16:05:12 -07:00
Daniel Dupriest
779079e775
Fix line wrapping. 2019-03-23 16:04:13 -07:00
kououken
5c52903c20 Added overview of the policy file and how to build a policy. 2019-03-23 16:01:46 -07:00
kououken
9011d3508e Added 's' to 'need'. 2019-03-23 15:15:27 -07:00
kououken
9e56fb8413 Add Rupika's last name to README.md. 2019-03-22 14:56:50 -07:00
kououken
37256dafc3 Remove user frank. 2019-03-22 14:54:47 -07:00
kououken
f0cf1ab91c Add testing info to README.md. 2019-03-22 14:50:44 -07:00
Preston Doman
e22b207136 Remove IE polyfill 2019-03-20 15:41:14 -07:00
Preston Doman
f579fa19a3 Rename reports script 2019-03-20 15:36:23 -07:00
Preston Doman
629f6948e4 Add comments 2019-03-15 21:40:11 -07:00
6 changed files with 174 additions and 12 deletions

View file

@ -4,7 +4,7 @@ Reimbursinator was developed by students at Portland State University with the s
## Development ## Development
Developed by: Daniel Dupriest, Logan Miller, Jack, Joe Arriaga, Preston, Rupika, Shuaiyi Liang Developed by: Daniel Dupriest, Logan Miller, Jack, Joe Arriaga, Preston, Rupika Dikkala, Shuaiyi Liang
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Please make sure to update tests as appropriate. Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Please make sure to update tests as appropriate.
@ -73,12 +73,81 @@ Once the server is up and running, log in to `https://backendaddress.com:port/ad
Open the "Sites" view, and edit the one existing site's name to match your server name. This will be used in system emails. Open the "Sites" view, and edit the one existing site's name to match your server name. This will be used in system emails.
#### Policy File
The policy file, located at `back/backend/policy.py` is the heart of the application, defining the report sections, fields and rules which make up a reimbursement policy. You must build a policy file using these components in order to display the relevant text to your users, as well as collect the necessary data and give feedback based on rules.
##### Sections
The `Section` constructor is used to build a logical "section" of the report. A section corresponds to one idea or topic for which certain data should be collected, and certain rules applied. For example, to create a "Lodging" section, use the `Section` constructor with the following parameters:
- `title`: The title you wish to display for that section
- `html_description`: A string of html used to describe the section to the user. This should contain any necessary instructions or links to reference materials. Plain text should be wrapped in paragraph `<p></p>` tags.
- `fields`: This is a python object with one or more fields (see next section) defined.
Example python:
```
lodging_section = Section(
title="Hotel / Lodging",
html_description="<p>Please submit a receipt from your hotel including both the total amount and the dates of your stay. Per diem rates can be found on <a href='https://www.gsa.gov/travel/plan-book/per-diem-rates' target='_blank'>the U.S. GSA website</a>.</p>",
fields={
"per_diem_rate": {"number": 0, "label": "USGSA Per diem rate", "field_type": "decimal"},
"cost": {"number": 1, "label": "Total cost for lodging", "field_type": "decimal"},
"check_in_date": {"number": 2, "label": "Check-in date", "field_type": "date"},
"check_out_date": {"number": 3, "label": "Check-out date", "field_type": "date"},
"invoice_screenshot": {"number": 4, "label": "Screenshot of invoice", "field_type": "file"},
}
)
```
A section can have any number of rules added to them. See the "Rules" section for more details.
Once a section is ready, add it to the policy file by calling:
```
pol.add_section(lodging_section)
```
Sections will be presented to the user in the order that they are added with this command.
##### Fields
Fields defined in the policy file for a section will appear as form fields in the application. When defined inside a Python object, they appear like this:
```
"per_diem_rate": {"number": 0, "label": "USGSA Per diem rate", "field_type": "decimal"},
```
- "per_diem_rate" becomes the key for this field, and is used to reference the field within rules for that section.
- "number" specifies the order in which the fields should be shown to the user.
- "label" is the text which will be displayed to the user for this field.
- "field_type" determines what type of data to get from the user. Depending on the type of field specified, the user will be prompted to fill in different types of information. The current supported types are:
- `boolean`: A true/false value. This is presented as "yes/no" to the user, and is useful to store the response to a yes/no question.
- `date`: An ISO date in YYYY-MM-DD format. The browser's native date picker should appear for this field
- `decimal`: A number with a whole part and fractional part up to two places. e.g. 10.50
- `integer`: An integer number. e.g. 10
- `file`: A generic file upload field. Currently this field can only hold one file at a time. To allow multiple file upload multiple fields should be provided.
- `string`: A string of unicode characters. e.g. "Portland"
##### Rules
Rules allow an administrator to validate the information entered by a user in a specific section and provide feedback messages if desired. Rules will be called with a dictionary of field values passed in via the `fields` parameter, and any string returned will be displayed to the user.
An example of a simple rule that checks the boolean value of the field named 'economy' to check if a user has purchased an economy class ticket would be as follows:
```
my_flight_section.add_rule(
title="Economy Check",
rule=lambda report, fields: "Only economy class tickets are allowed." if not fields['economy'] else None
```
For more complex, multi-line rules, a temporary function may be defined and passed to the "rule" parameter when adding a rule. See the included `policy.py` file content for examples of more complex rules. Currently, accessing fields from other sections via the "report" parameter is not supported.
### Admin Files ### Admin Files
In order to have CSS and JS working in the Django administrative pages, serve the contents of `admin/static` using the http server of your choice, and edit `back/reimbursinator/settings.py` to set the `STATIC_URL` address to the correct address to access these files. In order to have CSS and JS working in the Django administrative pages, serve the contents of `admin/static` using the http server of your choice, and edit `back/reimbursinator/settings.py` to set the `STATIC_URL` address to the correct address to access these files.
## Usage
## Tools, Libraries and Frameworks Used ## Tools, Libraries and Frameworks Used
The following are the versions used in development of Reimbursinator, and the versions pinned in `back/Pipfile`. The following are the versions used in development of Reimbursinator, and the versions pinned in `back/Pipfile`.
@ -96,5 +165,15 @@ The following are the versions used in development of Reimbursinator, and the ve
## Tests ## Tests
## Support ### Front end
Tests for front end code are implemented with Q-Unit, and are located in the `front/tests/` directory. To run them, open `qunit_tests.html` in a browser.
### Back end
Tests for back end code are implemented with Python and Django testing libraries. To run these:
1. From the `back/` directory, run `pipenv shell` to load Django modules.
2. Run `python manage.py test`.
One test file, `test_backend.py`, covers Django models, views, etc. The other test file, `test_policy.py`, is specific to the rules implemented in the provided `policy.py` file, and needs to be updated or removed as changes are made.

Binary file not shown.

View file

@ -8,7 +8,6 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/classlist/1.2.20171210/classList.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<link rel="shortcut icon" href="img/favicon.ico"> <link rel="shortcut icon" href="img/favicon.ico">
<title>Reimbursinator</title> <title>Reimbursinator</title>
@ -113,6 +112,6 @@
</div> </div>
</div> </div>
<script src="js/logout.js"></script> <script src="js/logout.js"></script>
<script src="js/viewHistory.js"></script> <script src="js/reports.js"></script>
</body> </body>
</html> </html>

View file

@ -8,6 +8,16 @@ function getEndpointDomain() {
return "https://" + window.location.hostname + ":8444/"; return "https://" + window.location.hostname + ":8444/";
} }
/*
XMLHttpRequest wrapper for requesting or sending data to/from the backend
method (string): HTTP request type
url (string): the url of an api endpoint
callback (function): function to execute on success
optional: optional argument
payload (JSON): optional JSON payload
returns: n/a
*/
function makeAjaxRequest(method, url, callback, optional, payload) { function makeAjaxRequest(method, url, callback, optional, payload) {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@ -50,6 +60,13 @@ function makeAjaxRequest(method, url, callback, optional, payload) {
xhr.send(payload); xhr.send(payload);
} }
/*
Disables a button and changes its display text
button (object): the button to update
buttonText (string): the new display text
returns: n/a
*/
function animateButton(button, buttonText) { function animateButton(button, buttonText) {
button.disabled = true; button.disabled = true;
button.innerHTML = ""; button.innerHTML = "";
@ -59,6 +76,14 @@ function animateButton(button, buttonText) {
button.appendChild(document.createTextNode(buttonText)); button.appendChild(document.createTextNode(buttonText));
} }
/*
Updates a section's header and footer based on its current state
parsedData (object): Object representing a section's data
saveButton (object): the button to update
returns: n/a
*/
function updateSection(parsedData, saveButton) { function updateSection(parsedData, saveButton) {
const sectionIdStr = "#section-" + parsedData.id + "-"; const sectionIdStr = "#section-" + parsedData.id + "-";
const sectionState = document.querySelector(sectionIdStr + "state"); const sectionState = document.querySelector(sectionIdStr + "state");
@ -80,6 +105,7 @@ function updateSection(parsedData, saveButton) {
} }
} }
// Add card footer with rule violations if needed
const cardFooter = createCardFooter(parsedData.rule_violations); const cardFooter = createCardFooter(parsedData.rule_violations);
if (collapseDiv.lastElementChild.classList.contains("card-footer")) { if (collapseDiv.lastElementChild.classList.contains("card-footer")) {
collapseDiv.removeChild(collapseDiv.lastElementChild); collapseDiv.removeChild(collapseDiv.lastElementChild);
@ -96,7 +122,13 @@ function updateSection(parsedData, saveButton) {
saveButton.disabled = false; saveButton.disabled = false;
} }
// Wraps a Bootstrap form group around a field /*
Wraps a Bootstrap form group around a field
sectionIdStr (string): section id prefix
field (object): Object representing a field
returns: div element with form group styling
*/
function createFormGroup(sectionIdStr, field) { function createFormGroup(sectionIdStr, field) {
const inputId = sectionIdStr + field.field_name; const inputId = sectionIdStr + field.field_name;
const formGroup = document.createElement("div") const formGroup = document.createElement("div")
@ -204,6 +236,15 @@ function createFormGroup(sectionIdStr, field) {
return formGroup; return formGroup;
} }
/*
Creates a card and card header
sectionIdStr (string): section id prefix
sectionTitle (string): section title for the collapse button
sectionCompleted (boolean): flag indicating section state
ruleViolations (array): list of policy rule violations for this section
returns: div element with card styling
*/
function createCollapsibleCard(sectionIdStr, sectionTitle, sectionCompleted, ruleViolations) { function createCollapsibleCard(sectionIdStr, sectionTitle, sectionCompleted, ruleViolations) {
// Create card and header // Create card and header
const card = document.createElement("div"); const card = document.createElement("div");
@ -239,6 +280,16 @@ function createCollapsibleCard(sectionIdStr, sectionTitle, sectionCompleted, rul
return card; return card;
} }
/*
Creates a collapsible card body
form (HTML element): form element
sectionIdStr (string): section id prefix
sectionDescription (string): HTML string description of this section
sectionCompleted (boolean): flag indicating section state
ruleViolations (array): list of policy rule violations for this section
returns: div element with collapse styling
*/
function createCollapsibleCardBody(form, sectionIdStr, sectionDescription, sectionCompleted, ruleViolations) { function createCollapsibleCardBody(form, sectionIdStr, sectionDescription, sectionCompleted, ruleViolations) {
// Create wrapper div // Create wrapper div
const collapseDiv = document.createElement("div"); const collapseDiv = document.createElement("div");
@ -252,6 +303,7 @@ function createCollapsibleCardBody(form, sectionIdStr, sectionDescription, secti
} else if (sectionCompleted && ruleViolations.length > 0) { } else if (sectionCompleted && ruleViolations.length > 0) {
collapseDiv.classList.add("collapse", "show"); collapseDiv.classList.add("collapse", "show");
} else { } else {
// Add section alert
sectionAlert.classList.add("alert", "alert-danger", "section-alert"); sectionAlert.classList.add("alert", "alert-danger", "section-alert");
sectionAlert.innerHTML = "This section is not complete"; sectionAlert.innerHTML = "This section is not complete";
collapseDiv.classList.add("collapse", "show"); collapseDiv.classList.add("collapse", "show");
@ -266,6 +318,12 @@ function createCollapsibleCardBody(form, sectionIdStr, sectionDescription, secti
return collapseDiv; return collapseDiv;
} }
/*
Creates a card footer and populates it with rule violations
ruleViolations (array): a list of policy rule violations
returns: div element with card footer styling
*/
function createCardFooter(ruleViolations) { function createCardFooter(ruleViolations) {
if (ruleViolations.length === 0) { if (ruleViolations.length === 0) {
return null; return null;
@ -296,6 +354,13 @@ function createCardFooter(ruleViolations) {
return cardFooter; return cardFooter;
} }
/*
Creates a form within a modal popup
parsedData (object): Object representing report data
type (number): The report type (edit or new)
returns: n/a
*/
function createReportForm(parsedData, type) { function createReportForm(parsedData, type) {
let modalBody; let modalBody;
let modalLabel; let modalLabel;
@ -376,6 +441,12 @@ function createReportForm(parsedData, type) {
modalBody.appendChild(accordion); modalBody.appendChild(accordion);
} }
/*
Displays a table containing all of a user's reports
parsedData (object): Object representing report data
returns: n/a
*/
function displayListOfReports(parsedData) { function displayListOfReports(parsedData) {
const reports = parsedData.reports; const reports = parsedData.reports;
const cardBody = document.querySelector(".card-body"); const cardBody = document.querySelector(".card-body");
@ -435,9 +506,13 @@ function displayListOfReports(parsedData) {
} }
} }
/*
Populates modal popup with a readonly, finalized report
parsedData (object): Object representing report data
returns: n/a
*/
function displayReport(parsedData){ function displayReport(parsedData){
//Able to get the correct report ID now just needs to display the
//report as an modual
const modalBody = document.querySelector(".modal-view"); const modalBody = document.querySelector(".modal-view");
const modalLabel = document.querySelector("#viewReportModalLabel"); const modalLabel = document.querySelector("#viewReportModalLabel");
@ -509,6 +584,7 @@ function displayReport(parsedData){
modalBody.appendChild(card); modalBody.appendChild(card);
} }
// Display list of reports on page load
document.addEventListener("DOMContentLoaded", function(event) { document.addEventListener("DOMContentLoaded", function(event) {
if (window.location.pathname === "/edit_report.html") { if (window.location.pathname === "/edit_report.html") {
const url = getEndpointDomain() + "api/v1/reports"; const url = getEndpointDomain() + "api/v1/reports";
@ -516,17 +592,21 @@ document.addEventListener("DOMContentLoaded", function(event) {
} }
}); });
// Listens for button click events
document.addEventListener("click", function(event) { document.addEventListener("click", function(event) {
if (event.target) { if (event.target) {
if (event.target.classList.contains("edit-report-button")) { if (event.target.classList.contains("edit-report-button")) {
// Edit button clicked
const url = getEndpointDomain() + "api/v1/report/" + event.target.dataset.rid; const url = getEndpointDomain() + "api/v1/report/" + event.target.dataset.rid;
const type = reportType.EDIT; const type = reportType.EDIT;
makeAjaxRequest("GET", url, createReportForm, type); makeAjaxRequest("GET", url, createReportForm, type);
} else if (event.target.classList.contains("view-report-button")) { } else if (event.target.classList.contains("view-report-button")) {
// View button clicked
console.log("View button clicked"); console.log("View button clicked");
const url = getEndpointDomain() + "api/v1/report/" + event.target.dataset.rid; const url = getEndpointDomain() + "api/v1/report/" + event.target.dataset.rid;
makeAjaxRequest("GET", url, displayReport); makeAjaxRequest("GET", url, displayReport);
} else if (event.target.classList.contains("review-report")) { } else if (event.target.classList.contains("review-report")) {
// Submit for review button clicked
event.preventDefault(); event.preventDefault();
const result = confirm("Are you sure you want to submit this report for review?"); const result = confirm("Are you sure you want to submit this report for review?");
if (result) { if (result) {
@ -540,6 +620,7 @@ document.addEventListener("click", function(event) {
}); });
} }
} else if (event.target.classList.contains("finalize-report")) { } else if (event.target.classList.contains("finalize-report")) {
// Finalize report button clicked
event.preventDefault(); event.preventDefault();
console.log("finalize-report"); console.log("finalize-report");
const result = confirm("Are you sure you want to finalize this report? This means you will no longer be able to modify it."); const result = confirm("Are you sure you want to finalize this report? This means you will no longer be able to modify it.");
@ -554,6 +635,7 @@ document.addEventListener("click", function(event) {
}); });
} }
} else if (event.target.classList.contains("delete-report")) { } else if (event.target.classList.contains("delete-report")) {
// Delete report button clicked
event.preventDefault(); event.preventDefault();
const title = document.querySelector("#editReportModalLabel").textContent; const title = document.querySelector("#editReportModalLabel").textContent;
const result = confirm("Are you sure you want to delete the report \"" + title + "\"?"); const result = confirm("Are you sure you want to delete the report \"" + title + "\"?");
@ -571,6 +653,7 @@ document.addEventListener("click", function(event) {
} }
}); });
// Listens for a create report submission
const newReportForm = document.querySelector(".new-report-form"); const newReportForm = document.querySelector(".new-report-form");
if (newReportForm) { if (newReportForm) {
newReportForm.addEventListener("submit", function(event) { newReportForm.addEventListener("submit", function(event) {
@ -583,6 +666,7 @@ if (newReportForm) {
}); });
} }
// Listens for a date input
document.addEventListener("input", function(event) { document.addEventListener("input", function(event) {
if (event.target.type === "date") { if (event.target.type === "date") {
if (!moment(event.target.value, "YYYY-MM-DD", true).isValid()) { if (!moment(event.target.value, "YYYY-MM-DD", true).isValid()) {
@ -593,6 +677,7 @@ document.addEventListener("input", function(event) {
} }
}); });
// Listens for a section saving event
document.addEventListener("submit", function(event) { document.addEventListener("submit", function(event) {
if (event.target.classList.contains("section-form")) { if (event.target.classList.contains("section-form")) {
event.preventDefault(); event.preventDefault();

View file

@ -8,7 +8,6 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/classlist/1.2.20171210/classList.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<link rel="shortcut icon" href="img/favicon.ico"> <link rel="shortcut icon" href="img/favicon.ico">
<title>Reimbursinator</title> <title>Reimbursinator</title>
@ -88,6 +87,6 @@
</div> </div>
</div> </div>
<script src="js/logout.js"></script> <script src="js/logout.js"></script>
<script src="js/viewHistory.js"></script> <script src="js/reports.js"></script>
</body> </body>
</html> </html>

View file

@ -4,7 +4,7 @@
<title>QUnit Tests</title> <title>QUnit Tests</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css"> <link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script src="../static/js/viewHistory.js"></script> <script src="../static/js/reports.js"></script>
<script src="testObjects.js"></script> <script src="testObjects.js"></script>
</head> </head>
<body> <body>