Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • develop
  • feature/frontend-tests
  • master
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.15
  • 0.16
  • 0.17
  • 0.18
  • 0.19
  • 0.2
  • 0.20
  • 0.21
  • 0.22
  • 0.23
  • 0.24
  • 0.25
  • 0.26
  • 0.27
  • 0.28
  • 0.29
  • 0.3
  • 0.30
  • 0.31
  • 0.32
  • 0.33
  • 0.34
  • 0.35
  • 0.36
  • 0.37
  • 0.38
  • 0.39
  • 0.4
  • 0.40
  • 0.41
  • 0.42
  • 0.43
  • 0.44
  • 0.45
  • 0.46
  • 0.47
  • 0.48
  • 0.49
  • 0.5
  • 0.50
  • 0.51
  • 0.52
  • 0.53
  • 0.54
  • 0.55
  • 0.56
  • 0.57
  • 0.58
  • 0.59
  • 0.6
  • 0.60
  • 0.61
  • 0.62
  • 0.63
  • 0.64
  • 0.65
  • 0.66
  • 0.67
  • 0.68
  • 0.69
  • 0.7
  • 0.70
  • 0.71
  • 0.72
  • 0.73
  • 0.74
  • 0.75
  • 0.76
  • 0.77
  • 0.78
  • 0.79
  • 0.8
  • 0.80
  • 0.81
  • 0.82
  • 0.83
  • 0.84
  • 0.85
  • 0.86
  • 0.87
  • 0.88
  • 0.89
  • 0.9
  • 0.90
  • 0.91
  • 0.92
  • 0.93
  • 0.94
  • 0.95
  • 0.96
  • 0.97
  • 0.98
101 results

Target

Select target project
  • geant-swd/compendium-v2
1 result
Select Git revision
  • develop
  • feature/frontend-tests
  • master
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.15
  • 0.16
  • 0.17
  • 0.18
  • 0.19
  • 0.2
  • 0.20
  • 0.21
  • 0.22
  • 0.23
  • 0.24
  • 0.25
  • 0.26
  • 0.27
  • 0.28
  • 0.29
  • 0.3
  • 0.30
  • 0.31
  • 0.32
  • 0.33
  • 0.34
  • 0.35
  • 0.36
  • 0.37
  • 0.38
  • 0.39
  • 0.4
  • 0.40
  • 0.41
  • 0.42
  • 0.43
  • 0.44
  • 0.45
  • 0.46
  • 0.47
  • 0.48
  • 0.49
  • 0.5
  • 0.50
  • 0.51
  • 0.52
  • 0.53
  • 0.54
  • 0.55
  • 0.56
  • 0.57
  • 0.58
  • 0.59
  • 0.6
  • 0.60
  • 0.61
  • 0.62
  • 0.63
  • 0.64
  • 0.65
  • 0.66
  • 0.67
  • 0.68
  • 0.69
  • 0.7
  • 0.70
  • 0.71
  • 0.72
  • 0.73
  • 0.74
  • 0.75
  • 0.76
  • 0.77
  • 0.78
  • 0.79
  • 0.8
  • 0.80
  • 0.81
  • 0.82
  • 0.83
  • 0.84
  • 0.85
  • 0.86
  • 0.87
  • 0.88
  • 0.89
  • 0.9
  • 0.90
  • 0.91
  • 0.92
  • 0.93
  • 0.94
  • 0.95
  • 0.96
  • 0.97
  • 0.98
101 results
Show changes
Commits on Source (12)
Showing
with 393 additions and 148 deletions
......@@ -23,6 +23,7 @@ logger = logging.getLogger(__name__)
class ROLES(Enum):
admin = "admin"
user = "user"
observer = "observer"
uuid_pk = Annotated[UUID, mapped_column(primary_key=True, default=lambda _: uuid4())]
......@@ -74,6 +75,10 @@ class User(UserMixin, db.Model):
def is_admin(self):
return self.roles == ROLES.admin
@property
def is_observer(self):
return self.roles == ROLES.observer
@property
def nren(self):
if len(self.nrens) == 0:
......
import logging
import alembic_postgresql_enum # type: ignore # noqa: F401
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
......
"""Add observer role
Revision ID: 51a29df6148c
Revises: 3730c7f1ea1b
Create Date: 2023-09-07 10:48:30.087825
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '51a29df6148c'
down_revision = '3730c7f1ea1b'
branch_labels = None
depends_on = None
def upgrade():
op.sync_enum_values('public', 'roles', ['admin', 'user', 'observer'],
[('user', 'roles')],
enum_values_to_rename=[])
def downgrade():
op.sync_enum_values('public', 'roles', ['admin', 'user'],
[('user', 'roles')],
enum_values_to_rename=[])
......@@ -77,12 +77,31 @@ class SurveyMode(str, Enum):
Edit = "edit"
def check_access_nren(user: User, nren: str) -> bool:
def check_access_nren_read(user: User, nren: str) -> bool:
if user.is_anonymous:
return False
if user.is_admin:
return True
if user.is_observer:
return True
if nren == user.nren:
return True
return False
def check_access_nren_write(user: User, nren: str) -> bool:
if not check_access_nren_read(user, nren):
# if you can't read it, you definitely shouldn't write to it
return False
if user.is_observer:
# observers can't edit their own nrens either!
return False
if user.is_admin:
# admins can edit all nrens
return True
if nren == user.nren:
# users can edit for the nren they are assigned to
return True
return False
......@@ -206,7 +225,7 @@ def load_survey(year, nren_name) -> Any:
if not survey:
return {'message': 'Survey not found'}, 404
if not check_access_nren(current_user, nren):
if not check_access_nren_read(current_user, nren):
return {'message': 'You do not have permissions to access this survey.'}, 403
response = db.session.scalar(
......@@ -215,6 +234,8 @@ def load_survey(year, nren_name) -> Any:
data, page, verification_status, locked_by = get_response_data(response, year, nren.id)
edit_allowed = current_user.is_admin or (
survey.status == SurveyStatus.open and check_access_nren_write(current_user, nren))
return {
"model": survey.survey,
"locked_by": locked_by,
......@@ -223,7 +244,7 @@ def load_survey(year, nren_name) -> Any:
"verification_status": verification_status,
"mode": SurveyMode.Display,
"status": response.status.value if response else RESPONSE_NOT_STARTED,
"edit_allowed": current_user.is_admin or survey.status == SurveyStatus.open
"edit_allowed": edit_allowed
}
......@@ -249,7 +270,7 @@ def lock_survey(year, nren_name) -> Any:
if not survey:
return {'message': 'Survey not found'}, 404
if not check_access_nren(current_user, nren):
if not check_access_nren_write(current_user, nren):
return {'message': 'You do not have permissions to access this survey.'}, 403
if survey.status != SurveyStatus.open and not current_user.is_admin:
......@@ -321,7 +342,7 @@ def save_survey(year, nren_name) -> Any:
if survey is None:
return {'message': 'Survey not found'}, 404
if not check_access_nren(current_user, nren):
if not check_access_nren_write(current_user, nren):
return {'message': 'You do not have permission to edit this survey.'}, 403
if survey.status != SurveyStatus.open and not current_user.is_admin:
......@@ -384,7 +405,7 @@ def unlock_survey(year, nren_name) -> Any:
if survey is None:
return {'message': 'Survey not found'}, 404
if not check_access_nren(current_user, nren):
if not check_access_nren_write(current_user, nren):
return {'message': 'You do not have permission to edit this survey.'}, 403
response = db.session.scalar(
......
......@@ -2,6 +2,7 @@ import logging
from typing import Any, TypedDict, List, Dict
from flask import Blueprint
from flask_login import login_required, current_user
from sqlalchemy import delete, select
from sqlalchemy.orm import joinedload, load_only
......@@ -47,7 +48,7 @@ LIST_SURVEYS_RESPONSE_SCHEMA = {
@routes.route('/list', methods=['GET'])
@common.require_accepts_json
@admin_required
@login_required
def list_surveys() -> Any:
"""
retrieve a list of surveys and responses, including their status
......@@ -58,6 +59,10 @@ def list_surveys() -> Any:
compendium_v2.routes.survey.LIST_SURVEYS_RESPONSE_SCHEMA
"""
if not (current_user.is_admin or current_user.is_observer):
return {'message': 'Insufficient privileges to access this resource'}, 403
surveys = db.session.scalars(
select(Survey).options(
load_only(Survey.year, Survey.status),
......@@ -74,21 +79,26 @@ def list_surveys() -> Any:
status: str
responses: List[Dict[str, str]]
entries: List[SurveyDict] = [
{
"year": entry.year,
"status": entry.status.value,
"responses": [
{
"nren": r.nren.name,
"status": r.status.value,
"lock_description": r.lock_description
}
for r in sorted(entry.responses, key=response_key)
]
entries: List[SurveyDict] = []
def _get_response(response: SurveyResponse) -> Dict[str, str]:
res = {
"nren": response.nren.name,
"status": response.status.value,
"lock_description": response.lock_description
}
for entry in surveys
]
if current_user.is_observer:
res["lock_description"] = response.lock_description
return res
for entry in surveys:
# only include lock description if the user is an admin
entries.append(
{
"year": entry.year,
"status": entry.status.value,
"responses": [_get_response(r) for r in sorted(entry.responses, key=response_key)]
})
# add in nrens without a response if the survey is open
nren_names = set([name for name in db.session.scalars(select(NREN.name))])
......
Source diff could not be displayed: it is too large. Options to address this: view the blob.
This diff is collapsed.
alembic~=1.10
# needed to support auto discovery of and applying enum changes
alembic-postgresql-enum~=0.1
click~=8.1
jsonschema~=4.17
flask~=2.2
......
......@@ -11,6 +11,7 @@ setup(
packages=find_packages(),
install_requires=[
'alembic~=1.10',
'alembic-postgresql-enum~=0.1',
'click~=8.1',
'jsonschema~=4.17',
'flask~=2.2',
......
import React, { ReactElement, useContext } from "react";
import React, { ReactElement, useContext, useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
import { Container, Row } from "react-bootstrap";
import { Table, Container, Row } from "react-bootstrap";
import { userContext } from "./providers/UserProvider";
import { fetchSurveys } from "./api/survey";
import { Survey } from "./api/types";
function Landing(): ReactElement {
const { user } = useContext(userContext);
......@@ -11,6 +14,7 @@ function Landing(): ReactElement {
const hasNren = loggedIn ? !!user.nrens.length : false;
const activeNren = hasNren ? user.nrens[0] : '';
const isAdmin = loggedIn ? user.permissions.admin : false;
const isObserver = loggedIn ? user.role === 'observer' : false;
const moveToSurvey = () => {
const currentYear = new Date().getFullYear();
......@@ -18,6 +22,40 @@ function Landing(): ReactElement {
return <></>
}
const SurveyTable = () => {
const [survey, setSurvey] = useState<Survey>();
useEffect(() => {
fetchSurveys().then((surveyList) => {
// only show the latest survey
setSurvey(surveyList[0]);
});
}, []);
return (<Table striped bordered responsive>
<thead>
<tr>
<th>(N)REN</th>
<th>Link</th>
<th>Survey Status</th>
</tr>
</thead>
<tbody>
{survey && survey.responses.map(response => (
<tr key={response.nren}>
<td>{response.nren}</td>
<td>
<Link to={`/survey/response/${survey.year}/${response.nren}`}><span>Navigate to survey</span></Link>
</td>
<td>{response.status}</td>
</tr>
))}
</tbody>
</Table>)
}
return (
<Container className="py-5 grey-container">
<Row>
......@@ -43,16 +81,14 @@ function Landing(): ReactElement {
<li><span>Click <Link to="/survey/admin/surveys">here</Link> to access the survey management page.</span></li>
<li><span>Click <Link to="/survey/admin/users">here</Link> to access the user management page.</span></li>
</ul> : <ul>
{!isAdmin && hasNren && moveToSurvey()}
{!isAdmin && !isObserver && hasNren && moveToSurvey()}
{loggedIn ? <li><span>You are logged in</span></li> : <li><span>You are not logged in</span></li>}
{loggedIn && !hasNren && <li><span>Your access to the survey has not yet been approved</span></li>}
{loggedIn && !hasNren && <li><span>Once you have been approved, you will immediately be directed to the relevant survey upon visiting this page</span></li>}
{loggedIn && !isObserver && !hasNren && <li><span>Your access to the survey has not yet been approved</span></li>}
{loggedIn && !isObserver && !hasNren && <li><span>Once you have been approved, you will immediately be directed to the relevant survey upon visiting this page</span></li>}
{loggedIn && isObserver && <li><span>You have read-only access to the following surveys:</span></li>}
</ul>}
{loggedIn && isObserver && <SurveyTable />}
</div>
</div>
</Row>
</Container >
......
......@@ -4,30 +4,8 @@ import Button from 'react-bootstrap/Button';
import Table from 'react-bootstrap/Table';
import { useNavigate } from 'react-router-dom';
import { ResponseStatus, SurveyStatus } from "./Schema";
async function fetchSurveys(): Promise<Survey[]> {
try {
const response = await fetch('/api/survey/list');
const userList = await response.json();
return userList
} catch (error) {
console.log('failed fetching survey list..');
return [];
}
}
interface Response {
nren: string
status: string
lock_description: string
}
interface Survey {
year: number
status: string
responses: Response[]
}
import { fetchSurveys } from "./api/survey";
import { Survey } from "./api/types";
function SurveyManagementComponent() {
const [surveys, setSurveys] = useState<Survey[]>([]);
......
......@@ -49,15 +49,36 @@ const updateUser = async (id, changes) => {
return data.user;
};
const defaultSortFunction = (a, b) => {
if (a.permissions.active && !b.permissions.active) {
return -1;
} else if (!a.permissions.active && b.permissions.active) {
return 1;
} else if (a.permissions.active && b.permissions.active) {
if (a.role === 'admin' && b.role !== 'admin') {
return 1;
} else if (a.role !== 'admin' && b.role === 'admin') {
return -1;
} else {
return a.name.localeCompare(b.name)
}
} else {
return a.name.localeCompare(b.name)
}
}
function UserManagementComponent() {
const [users, setUsers] = useState<User[]>([]);
const [nrens, setNrens] = useState<Nren[]>([]);
const { user: loggedInUser } = useContext(userContext);
const [sortFunc, setSortFunc] = useState({ idx: -1, func: defaultSortFunction, asc: true });
const [sortedUsers, setSortedUsers] = useState<User[]>([]);
useEffect(() => {
fetchUsers().then((userList) => {
setUsers(userList);
setSortedUsers(userList.sort(sortFunc.func))
});
fetchNrens().then((nrenList) => {
......@@ -93,6 +114,86 @@ function UserManagementComponent() {
})?.id
}
const setSort = (index) => {
if (index === sortFunc.idx || ((index === 5 || index === 0) && sortFunc.idx === -1)) {
// reverse sort
if (index === 5 || index === 0) {
// hack not to show the sort icon on column 0 and 5
index = -1
}
setSortFunc({ idx: index, asc: !sortFunc.asc, func: (a, b) => sortFunc.func(b, a) })
return
}
if (index === 0) {
// user ID is a UUID which is meaningless to sort by, so use default sort function
setSortFunc({ idx: -1, asc: true, func: defaultSortFunction })
} else if (index === 1) {
// sort by active
setSortFunc({
idx: index, asc: true, func: (a, b) => {
if (a.permissions.active && !b.permissions.active) {
return -1;
} else if (!a.permissions.active && b.permissions.active) {
return 1;
} else {
return 0;
}
}
})
} else if (index === 2) {
// sort by role
setSortFunc({
idx: index, asc: true, func: (a, b) => {
return a.role.localeCompare(b.role)
}
})
} else if (index === 3) {
// sort by email
setSortFunc({
idx: index, asc: true, func: (a, b) => {
return a.email.localeCompare(b.email)
}
})
} else if (index === 4) {
// sort by name
setSortFunc({
idx: index, asc: true, func: (a, b) => {
return a.name.localeCompare(b.name)
}
})
} else if (index === 5) {
// use the default sort function, OIDC sub has no meaning when sorting
setSortFunc({ idx: -1, asc: true, func: defaultSortFunction })
} else if (index === 6) {
// sort by NREN
setSortFunc({
idx: index, asc: true, func: (a, b) => {
if (a.nrens.length === 0 && b.nrens.length === 0) {
return 0;
} else if (a.nrens.length === 0) {
return -1;
} else if (b.nrens.length === 0) {
return 1;
} else {
return a.nrens[0].localeCompare(b.nrens[0])
}
}
})
} else {
setSortFunc({ idx: index, asc: true, func: defaultSortFunction })
}
setSortedUsers(users.sort(sortFunc.func))
}
// build the aria-sort attribute for each column with spreadable objects..
// this is apparently the easiest way to do it conditionally
const ariaSort = {}
for (let i = 0; i <= 6; i++) {
ariaSort[i] = sortFunc.idx === i ? ({ 'aria-sort': sortFunc.asc ? 'ascending' : 'descending' }) : null
}
return (
<Container style={{ maxWidth: '90vw', }}>
<Row>
......@@ -100,17 +201,17 @@ function UserManagementComponent() {
<Table>
<thead>
<tr>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>Id</th>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>Active</th>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>Role</th>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>Email</th>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>Full Name</th>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>OIDC Sub</th>
<th className='pt-3' style={{ border: "1px solid #ddd" }}>NREN</th>
<th {...ariaSort[0]} onClick={() => setSort(0)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Id</th>
<th {...ariaSort[1]} onClick={() => setSort(1)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Active</th>
<th {...ariaSort[2]} onClick={() => setSort(2)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Role</th>
<th {...ariaSort[3]} onClick={() => setSort(3)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Email</th>
<th {...ariaSort[4]} onClick={() => setSort(4)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Full Name</th>
<th {...ariaSort[5]} onClick={() => setSort(5)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>OIDC Sub</th>
<th {...ariaSort[6]} onClick={() => setSort(6)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>NREN</th>
</tr>
</thead>
<tbody>
{users.map(user => (
{sortedUsers.map(user => (
<tr key={user.id}>
<td style={{ border: "1px dotted #ddd" }}>{user.id}</td>
<td style={{ border: "1px dotted #ddd" }}>
......@@ -130,6 +231,7 @@ function UserManagementComponent() {
onChange={(event) => handleInputChange(event, user)}>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="observer">Observer</option>
</select>}
</td>
......
import { Survey } from './types';
export async function fetchSurveys(): Promise<Survey[]> {
try {
const response = await fetch('/api/survey/list');
const userList = await response.json();
return userList
} catch (error) {
console.log('failed fetching survey list..');
return [];
}
}
\ No newline at end of file
interface SurveyResponse {
nren: string
status: string
lock_description: string
}
interface Survey {
year: number
status: string
responses: SurveyResponse[]
}
export type {
Survey,
SurveyResponse
}
\ No newline at end of file
......@@ -182,3 +182,25 @@
.survey-tooltip:hover::before {
display: block;
}
.sortable {
cursor: pointer;
}
.sortable:hover {
text-decoration: dotted underline;
}
th.sortable[aria-sort="descending"]::after {
content: "▼";
color: currentcolor;
font-size: 100%;
margin-left: 0.25rem;
}
th.sortable[aria-sort="ascending"]::after {
content: "▲";
color: currentcolor;
font-size: 100%;
margin-left: 0.25rem;
}
\ No newline at end of file
......@@ -57,6 +57,19 @@ def mocked_user(app, test_survey_data, mocker):
yield user
@pytest.fixture
def mocked_observer_user(app, test_survey_data, mocker):
with app.app_context():
user = User(email='observer123@email.local', fullname='observerfullname',
oidc_sub='fakesub', roles=ROLES.observer)
db.session.add(user)
def user_loader(*args):
return user
mocker.patch('flask_login.utils._get_user', user_loader)
@pytest.fixture
def test_budget_data(app):
with app.app_context():
......
......@@ -158,3 +158,33 @@ def test_response_route_lock_prevents_other_edits(app, mocker, client, test_surv
assert rv.status_code == 403
result = json.loads(rv.data.decode('utf-8'))
assert result.get('message') == 'This survey is already locked.'
def test_response_routes_observer(app, client, test_survey_data, mocked_observer_user):
# observers should not be able to modify surveys, but should be able to view all of them
rv = client.get(
'/api/survey/list',
headers={'Accept': ['application/json']})
assert rv.status_code == 200
surveys = json.loads(rv.data.decode('utf-8'))
assert surveys
# load the first survey and check that the observer can view it
rv = client.get(
f'/api/response/load/{surveys[0]["year"]}/nren1',
headers={'Accept': ['application/json']})
assert rv.status_code == 200
# try to lock the first survey and check that the observer can't
rv = client.post(
f'/api/response/lock/{surveys[0]["year"]}/nren1',
headers={'Accept': ['application/json']})
assert rv.status_code == 403
# try to save the first survey and check that the observer can't
rv = client.post(
f'/api/response/save/{surveys[0]["year"]}/nren1',
headers={'Accept': ['application/json']})
assert rv.status_code == 403