Skip to content
Snippets Groups Projects
Commit d22ad72b authored by geant-release-service's avatar geant-release-service
Browse files

Finished release 0.30.

parents 0835923f 2f0aef0c
Branches
No related tags found
No related merge requests found
Showing
with 330 additions and 88 deletions
......@@ -2,6 +2,10 @@
All notable changes to this project will be documented in this file.
## [0.30] - 2023-08-22
- COMP-257: Added mail notification for new users on signup
- COMP-262: Changed text on the survey landing page
- Added download chart as CSV/PNG/SVG functionality to the reporting pages
## [0.29] - 2023-08-20
- More fixes to the survey model
......
......@@ -14,6 +14,7 @@
"chart.js": "^4.2.1",
"chartjs-plugin-datalabels": "^2.2.0",
"core-js": "^3.26.1",
"dom-to-image": "^2.6.0",
"install": "^0.13.0",
"npm": "^9.2.0",
"react": "^18.2.0",
......@@ -5250,6 +5251,11 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-to-image": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
"integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
......@@ -19029,6 +19035,11 @@
"entities": "^2.0.0"
}
},
"dom-to-image": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
"integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
},
"domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
......
......@@ -47,6 +47,7 @@
"chart.js": "^4.2.1",
"chartjs-plugin-datalabels": "^2.2.0",
"core-js": "^3.26.1",
"dom-to-image": "^2.6.0",
"install": "^0.13.0",
"npm": "^9.2.0",
"react": "^18.2.0",
......
import React, { ReactElement } from "react";
import React, {ReactElement} from "react";
import SidebarProvider from "./helpers/SidebarProvider";
import UserProvider from "./shared/UserProvider";
import FilterSelectionProvider from "./helpers/FilterSelectionProvider";
import ChartContainerProvider from "./helpers/ChartContainerProvider";
function Providers({ children }): ReactElement {
......@@ -10,7 +11,9 @@ function Providers({ children }): ReactElement {
<SidebarProvider>
<UserProvider>
<FilterSelectionProvider>
{children}
<ChartContainerProvider>
{children}
</ChartContainerProvider>
</FilterSelectionProvider>
</UserProvider>
</SidebarProvider>
......
import React, { useContext, useState } from 'react';
import domtoimage from 'dom-to-image';
import { ImageType } from "../helpers/constants";
import { ChartContainerContext } from "../helpers/ChartContainerProvider";
interface DownloadImageChartProps {
filename: string;
}
const DownloadImageChartButton: React.FC<DownloadImageChartProps> = ({ filename }) => {
const chartContainerRef = useContext(ChartContainerContext);
const [exportType, setExportType] = useState<ImageType>(ImageType.PNG);
const downloadChartAsImage = async () => {
if (chartContainerRef?.current) {
const scale = 1;
const commonStyle = {
transform: `scale(${scale})`,
'transform-origin': 'top left',
background: 'white'
};
let dataUrl;
switch (exportType) {
case ImageType.JPEG:
dataUrl = await domtoimage.toJpeg(chartContainerRef.current, {
quality: 0.95,
style: commonStyle
});
break;
case ImageType.SVG:
dataUrl = await domtoimage.toSvg(chartContainerRef.current, {
style: commonStyle
});
break;
case ImageType.PNG:
default:
dataUrl = await domtoimage.toPng(chartContainerRef.current, {
style: commonStyle
});
break;
}
const link = document.createElement('a');
link.href = typeof dataUrl === 'string' ? dataUrl : URL.createObjectURL(dataUrl);
link.download = `${filename}.${exportType}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
return (
<>
<select value={exportType} onChange={(e) => setExportType(e.target.value as ImageType)}>
<option value={ImageType.PNG}>PNG</option>
<option value={ImageType.JPEG}>JPEG</option>
<option value={ImageType.SVG}>SVG</option>
</select>
<button onClick={downloadChartAsImage}>Download Chart</button>
</>
);
}
export default DownloadImageChartButton;
import React from 'react';
import ChartContainer from "./graphing/ChartContainer";
interface InputProps {
......@@ -13,12 +14,11 @@ const WithLegend: React.FC<InputProps> = ({ children, location }) => {
const bottom = location === "bottom" || location === "both";
return (
<>
<ChartContainer>
{top && <div style={{ paddingLeft: '33%', 'paddingTop': '2.5rem', 'paddingBottom': '1.5rem' }} id={'legendtop'} />}
{children}
{bottom && <div style={{ paddingLeft: '33%', 'paddingTop': '1.5rem' }} id={'legendbottom'} />}
</>
</ChartContainer>
);
};
......
import React, { useContext, ReactNode } from 'react';
import { ChartContainerContext } from '../../helpers/ChartContainerProvider';
interface ChartContainerProps {
children: ReactNode;
}
const ChartContainer: React.FC<ChartContainerProps> = ({ children }) => {
const containerRef = useContext(ChartContainerContext);
return (
<div ref={containerRef}>
{children}
</div>
);
};
export default ChartContainer;
import React, { createContext, useRef, ReactNode, RefObject } from 'react';
const ChartContainerContext = createContext<RefObject<HTMLDivElement> | null>(null);
interface ChartContainerProviderProps {
children: ReactNode;
}
const ChartContainerProvider: React.FC<ChartContainerProviderProps> = ({ children }) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
return (
<ChartContainerContext.Provider value={chartContainerRef}>
{children}
</ChartContainerContext.Provider>
);
};
export { ChartContainerContext };
export default ChartContainerProvider;
......@@ -9,4 +9,10 @@ export enum Sections {
export enum ExportType {
CSV = "CSV",
EXCEL = "EXCEL",
}
export enum ImageType {
PNG = "png",
JPEG = "jpeg",
SVG = "svg",
}
\ No newline at end of file
......@@ -9,6 +9,8 @@ import LineGraph from "../components/graphing/LineGraph";
import {ExportType, Sections} from '../helpers/constants';
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from '../helpers/FilterSelectionProvider';
import DownloadImageChartButton from "../components/DownloadImageChartButton";
import ChartContainer from "../components/graphing/ChartContainer";
function BudgetPage(): ReactElement {
......@@ -44,10 +46,14 @@ function BudgetPage(): ReactElement {
<>
<Row>
<DownloadDataButton data={budgetResponse} filename="budget_data.csv" exportType={ExportType.CSV}/>
<DownloadDataButton data={budgetResponse} filename="budget_data.xlsx" exportType={ExportType.EXCEL}/>
<DownloadDataButton data={budgetResponse} filename="budget_data.xlsx"
exportType={ExportType.EXCEL}/>
</Row>
<Row>
<LineGraph data={budgetData} />
<DownloadImageChartButton filename="budget_data"/>
<ChartContainer>
<LineGraph data={budgetData}/>
</ChartContainer>
</Row>
</>
</DataPage>
......
......@@ -10,6 +10,8 @@ import Filter from "../components/graphing/Filter";
import {ExportType, Sections} from "../helpers/constants";
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from "../helpers/FilterSelectionProvider";
import DownloadImageChartButton from "../components/DownloadImageChartButton";
import ChartContainer from "../components/graphing/ChartContainer";
ChartJS.register(
CategoryScale,
......@@ -57,41 +59,44 @@ function ChargingStructurePage(): React.ReactElement {
<DownloadDataButton data={chargingStructureData} filename="charging_mechanism_of_nrens_per_year.csv" exportType={ExportType.CSV}/>
<DownloadDataButton data={chargingStructureData} filename="charging_mechanism_of_nrens_per_year.xlsx" exportType={ExportType.EXCEL} />
</Row>
<Table className="charging-struct-table" striped bordered responsive>
<colgroup>
<col span={1} style={{width: "10%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
</colgroup>
<thead>
<tr>
<th></th>
<th>Flat fee based on bandwidth</th>
<th>Usage based fee</th>
<th>Combination flat fee & usage basedfee</th>
<th>No Direct Charge</th>
<th>Other</th>
</tr>
</thead>
<tbody>
{Array.from(dataLookup.entries()).map(([nren, nrenMap]) => (
<tr key={nren}>
<td>{nren}</td>
{["flat_fee", "usage_based_fee", "combination", "no_charge", "other"].map(discriminator => (
<td key={discriminator}>
{Array.from(nrenMap.entries()).map(([year, chargingMechanism]) => (
<ColorPill key={year} year={year} active={chargingMechanism == discriminator}/>
))}
</td>
))}
<DownloadImageChartButton filename="charging_mechanism_of_nrens_per_year"/>
<ChartContainer>
<Table className="charging-struct-table" striped bordered responsive>
<colgroup>
<col span={1} style={{width: "10%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
<col span={1} style={{width: "18%"}}/>
</colgroup>
<thead>
<tr>
<th></th>
<th>Flat fee based on bandwidth</th>
<th>Usage based fee</th>
<th>Combination flat fee & usage basedfee</th>
<th>No Direct Charge</th>
<th>Other</th>
</tr>
))}
</tbody>
</Table>
</thead>
<tbody>
{Array.from(dataLookup.entries()).map(([nren, nrenMap]) => (
<tr key={nren}>
<td>{nren}</td>
{["flat_fee", "usage_based_fee", "combination", "no_charge", "other"].map(discriminator => (
<td key={discriminator}>
{Array.from(nrenMap.entries()).map(([year, chargingMechanism]) => (
<ColorPill key={year} year={year}
active={chargingMechanism == discriminator}/>
))}
</td>
))}
</tr>
))}
</tbody>
</Table>
</ChartContainer>
</>
</DataPage>
);
......
......@@ -8,6 +8,8 @@ import Filter from "../components/graphing/Filter"
import {ExportType, Sections} from '../helpers/constants';
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from '../helpers/FilterSelectionProvider';
import DownloadImageChartButton from "../components/DownloadImageChartButton";
import ChartContainer from "../components/graphing/ChartContainer";
function getJSXFromMap(data: Map<string, Map<number, ECProject[]>>) {
......@@ -62,18 +64,21 @@ function ECProjects() {
<DownloadDataButton data={projectData} filename="nren_involvement_in_european_commission_projects.csv" exportType={ExportType.CSV}/>
<DownloadDataButton data={projectData} filename="nren_involvement_in_european_commission_projects.xlsx" exportType={ExportType.EXCEL}/>
</Row>
<Table borderless className='compendium-table'>
<thead>
<tr>
<th className='nren-column'>NREN</th>
<th className='year-column'>Year</th>
<th className='blue-column'>EC Project Membership</th>
</tr>
</thead>
<tbody>
{getJSXFromMap(projectDataByYear)}
</tbody>
</Table>
<DownloadImageChartButton filename="nren_involvement_in_european_commission_projects"/>
<ChartContainer>
<Table borderless className='compendium-table'>
<thead>
<tr>
<th className='nren-column'>NREN</th>
<th className='year-column'>Year</th>
<th className='blue-column'>EC Project Membership</th>
</tr>
</thead>
<tbody>
{getJSXFromMap(projectDataByYear)}
</tbody>
</Table>
</ChartContainer>
</>
</DataPage>
)
......
......@@ -12,6 +12,9 @@ import {ExportType, Sections} from '../helpers/constants';
import ColorBadge from '../components/ColorBadge';
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from '../helpers/FilterSelectionProvider';
import DownloadImageChartButton from "../components/DownloadImageChartButton";
import { ChartContainerContext } from "../helpers/ChartContainerProvider";
import ChartContainer from "../components/graphing/ChartContainer";
export const chartOptions = {
maintainAspectRatio: false,
......@@ -156,17 +159,18 @@ function FundingSourcePage() {
<DownloadDataButton data={fundingSourceData} filename="income_source_of_nren_per_year.csv" exportType={ExportType.CSV}/>
<DownloadDataButton data={fundingSourceData} filename="income_source_of_nren_per_year.xlsx" exportType={ExportType.EXCEL}/>
</Row>
<div>
<FundingSourceLegend/>
<div className="chart-container" style={{'height': `${height}rem`}}>
<Bar
plugins={[ChartDataLabels]}
data={fundingSourceDataset}
options={chartOptions}
/>
</div>
<FundingSourceLegend/>
<DownloadImageChartButton filename="income_source_of_nren_per_year"/>
<ChartContainer>
<FundingSourceLegend/>
<div className="chart-container" style={{'height': `${height}rem`}}>
<Bar
plugins={[ChartDataLabels]}
data={fundingSourceDataset}
options={chartOptions}
/>
</div>
<FundingSourceLegend/>
</ChartContainer>
</>
</DataPage>
);
......
......@@ -8,6 +8,8 @@ import Filter from "../components/graphing/Filter"
import {ExportType, Sections} from '../helpers/constants';
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from '../helpers/FilterSelectionProvider';
import DownloadImageChartButton from "../components/DownloadImageChartButton";
import ChartContainer from "../components/graphing/ChartContainer";
function getJSXFromMap(data: Map<string, Map<number, Organisation[]>>) {
......@@ -56,18 +58,21 @@ function ParentOrganisation() {
<DownloadDataButton data={organisationData} filename="nren_parent_organisations.csv" exportType={ExportType.CSV}/>
<DownloadDataButton data={organisationData} filename="nren_parent_organisations.xlsx" exportType={ExportType.EXCEL}/>
</Row>
<Table borderless className='compendium-table'>
<thead>
<tr>
<th className='nren-column'>NREN</th>
<th className='year-column'>Year</th>
<th className='blue-column'>Parent Organisation</th>
</tr>
</thead>
<tbody>
{getJSXFromMap(organisationDataset)}
</tbody>
</Table>
<DownloadImageChartButton filename='nren_parent_organisations'/>
<ChartContainer>
<Table borderless className='compendium-table'>
<thead>
<tr>
<th className='nren-column'>NREN</th>
<th className='year-column'>Year</th>
<th className='blue-column'>Parent Organisation</th>
</tr>
</thead>
<tbody>
{getJSXFromMap(organisationDataset)}
</tbody>
</Table>
</ChartContainer>
</>
</DataPage>
......
......@@ -12,6 +12,7 @@ import htmlLegendPlugin from '../plugins/HTMLLegendPlugin';
import {Row} from "react-bootstrap";
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from '../helpers/FilterSelectionProvider';
import DownloadImageChartButton from "../components/DownloadImageChartButton";
ChartJS.register(
CategoryScale,
......@@ -163,6 +164,7 @@ function StaffGraph({ roles = false }: inputProps) {
<DownloadDataButton data={staffData} filename={filename} exportType={ExportType.CSV}/>
<DownloadDataButton data={staffData} filename={filename} exportType={ExportType.EXCEL}/>
</Row>
<DownloadImageChartButton filename={filename}/>
<WithLegend>
<div className="chart-container" style={{'height': `${height}rem`}}>
<Bar
......
......@@ -8,7 +8,8 @@ import Filter from "../components/graphing/Filter"
import {ExportType, Sections} from '../helpers/constants';
import DownloadDataButton from "../components/DownloadDataButton";
import { FilterSelectionContext } from '../helpers/FilterSelectionProvider';
import DownloadImageChartButton from "../components/DownloadImageChartButton";
import ChartContainer from "../components/graphing/ChartContainer";
function getJSXFromMap(data: Map<string, Map<number, Organisation[]>>) {
return Array.from(data.entries()).map(([nren, nrenMap]) => {
......@@ -62,18 +63,21 @@ function SubOrganisation() {
<DownloadDataButton data={organisationData} filename="nren_suborganisations.csv" exportType={ExportType.CSV}/>
<DownloadDataButton data={organisationData} filename="nren_suborganisations.xlsx" exportType={ExportType.EXCEL}/>
</Row>
<Table borderless className='compendium-table'>
<thead>
<tr>
<th className='nren-column'>NREN</th>
<th className='year-column'>Year</th>
<th className='blue-column'>Suborganisation and Role</th>
</tr>
</thead>
<tbody>
{getJSXFromMap(organisationDataset)}
</tbody>
</Table>
<DownloadImageChartButton filename="nren_suborganisations"/>
<ChartContainer>
<Table borderless className='compendium-table'>
<thead>
<tr>
<th className='nren-column'>NREN</th>
<th className='year-column'>Year</th>
<th className='blue-column'>Suborganisation and Role</th>
</tr>
</thead>
<tbody>
{getJSXFromMap(organisationDataset)}
</tbody>
</Table>
</ChartContainer>
</>
</DataPage>
);
......
......@@ -67,6 +67,17 @@ def _create_app_with_db(app_config) -> Flask:
# for the publishers
app.config['SQLALCHEMY_BINDS'] = app_config['SQLALCHEMY_BINDS']
if 'mail' in app_config:
mail_config = app_config['mail']
app.config['MAIL_ENABLE'] = True
app.config['MAIL_SERVER'] = mail_config['MAIL_SERVER']
app.config['MAIL_PORT'] = mail_config['MAIL_PORT']
app.config['MAIL_SENDER_EMAIL'] = mail_config['MAIL_SENDER_EMAIL'] # email address to send emails from
excluded_admins = mail_config.get('MAIL_EXCLUDED_ADMINS', [])
app.config['MAIL_EXCLUDED_ADMINS'] = excluded_admins # list of admin emails not to send emails to
else:
app.config['MAIL_ENABLE'] = False
db.init_app(app)
return app
......
......@@ -5,6 +5,7 @@ from datetime import datetime
from flask_login import LoginManager, current_user # type: ignore
from compendium_v2.db import session_scope
from compendium_v2.db.auth_model import User, ROLES
from compendium_v2.email import send_mail
def admin_required(func):
......@@ -45,6 +46,7 @@ def create_user(email: str, fullname: str, oidc_sub: str):
with session_scope() as session:
user = User(email=email, fullname=fullname, oidc_sub=oidc_sub)
session.add(user)
send_mail(f'{fullname} has just signed up with the email {email} and provider ID {oidc_sub}')
return user
......
......@@ -17,6 +17,20 @@ CONFIG_SCHEMA = {
'required': ['client_id', 'client_secret', 'server_metadata_url'],
'additionalProperties': False
},
'mail': {
'type': 'object',
'properties': {
'MAIL_SERVER': {'type': 'string'},
'MAIL_PORT': {'type': 'integer'},
'MAIL_SENDER_EMAIL': {'type': 'string', 'format': 'email'},
'MAIL_EXCLUDED_ADMINS': {
'type': 'array',
'items': {'type': 'string', 'format': 'email'}
}
},
'required': ['MAIL_SERVER', 'MAIL_PORT', 'MAIL_SENDER_EMAIL'],
'additionalProperties': False
},
'SECRET_KEY': {'type': 'string'},
},
'required': ['SQLALCHEMY_DATABASE_URI', 'SURVEY_DATABASE_URI', 'SECRET_KEY'],
......
import smtplib
import threading
import logging
from typing import Sequence, Union
from sqlalchemy import select
from flask import current_app
from compendium_v2.db import db
from compendium_v2.db.auth_model import User, ROLES
logger = logging.getLogger(__name__)
def _send_mail(smtp_server, port, sender_email, recipients, message):
try:
with smtplib.SMTP(smtp_server, port) as server:
server.sendmail(from_addr=sender_email, to_addrs=recipients, msg=message)
logger.debug('Successfully sent email')
except Exception:
logger.exception('Unable to send email:')
def send_mail(
contents: str,
subject: str = 'New user signed up for Compendium',
recipients: Union[str, Sequence[str]] = ''
):
if not current_app.config['MAIL_ENABLE']:
logger.warning('No mail configuration, cannot send email.')
return
if not contents or not isinstance(contents, str):
raise ValueError('Contents must be a non-empty string.')
excluded_admins = set(email.lower() for email in current_app.config['MAIL_EXCLUDED_ADMINS'])
admins = db.session.scalars(select(User).where(User.roles == ROLES.admin)).unique().all()
admin_emails = [admin.email for admin in admins if admin.email.lower() not in excluded_admins]
if not recipients:
recipients = admin_emails
subject = subject.replace('\n', ' ')
message = f"""Subject: {subject}\n\n{contents}"""
smtp_server = current_app.config['MAIL_SERVER']
port = current_app.config['MAIL_PORT']
sender_email = current_app.config['MAIL_SENDER_EMAIL']
# spin off a thread since this can take some time..
logger.debug('Sending email')
thread = threading.Thread(target=_send_mail, args=(smtp_server, port, sender_email, recipients, message))
thread.start()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment