dataset
The dataset feature is the center of uvlhub. It owns the dataset lifecycle end to end: the upload
form and its staging area, persistence of a dataset with its metadata and authors, the push to Zenodo
that mints the DOI, the public DOI landing page, the zipped download, per-dataset view and download
metrics, and the REST API at /api/v1/datasets/. It lives in app/features/dataset/.
Table of contents
- What it does
- Routes
- REST API
- Models
- Services and repositories
- Dependencies
- Templates and assets
- Tests
- Configuration
What it does
A dataset is a set of UVL feature models uploaded together under one metadata record. The flow is:
- The logged-in user opens
/dataset/upload. Each UVL file dropped on the page is POSTed to/dataset/file/uploadand staged in the user’s temp folder; a staged file can be removed again through/dataset/file/delete. - Submitting the form POSTs to
/dataset/upload.DataSetService.upload_datasetpersists the dataset, its metadata, authors, feature models and file rows locally, moves the staged files underuploads/user_<id>/dataset_<id>/, and then pushes everything to Zenodo. The Zenodo half is best-effort: if it fails, the dataset stays in the hub without a DOI and the call answers{"status": "partial"}instead of an error (see Zenodo). - Once a DOI is stored, the dataset is synchronized and its landing page is served at
/doi/<doi>/to anyone, no login required.
Routes
Real output of rosemary route:list for this blueprint:
Endpoint Methods Route
dataset.assets GET /dataset/<subfolder>/<filename>
dataset.create_dataset GET /dataset/upload
dataset.dataset DELETE, GET, POST, PUT /api/v1/datasets/<int:id>
dataset.datasets DELETE, GET, POST, PUT /api/v1/datasets/
dataset.delete POST /dataset/file/delete
dataset.download_dataset GET /dataset/download/<int:dataset_id>
dataset.get_unsynchronized_dataset GET /dataset/unsynchronized/<int:dataset_id>/
dataset.list_dataset GET, POST /dataset/list
dataset.subdomain_index GET /doi/<path:doi>/
dataset.upload POST /dataset/file/upload
dataset.upload_dataset POST /dataset/upload
| Route | Auth | What it does |
|---|---|---|
/dataset/upload (GET) |
login | Renders the upload form. |
/dataset/upload (POST) |
login | Validates the form and runs DataSetService.upload_dataset. |
/dataset/file/upload |
login | Stages one UVL file in the user’s temp folder. Rejects anything that is not .uvl; colliding names get a " (n)" suffix. |
/dataset/file/delete |
login | Removes a staged file. Anonymous requests are redirected to the login form. |
/dataset/list |
login | The user’s synchronized and unsynchronized datasets, newest first. |
/dataset/download/<id> |
public | Zips the dataset’s files and serves the archive. |
/doi/<doi>/ |
public | DOI landing page. Old DOIs recorded in DOIMapping are 302-redirected to the new one; unknown DOIs answer 404. |
/dataset/unsynchronized/<id>/ |
login | Detail page for a dataset that has no DOI yet, visible only to its owner. |
/dataset/<subfolder>/<filename> |
public | The feature’s static assets. |
Two details of /dataset/download/<id> are deliberate. The download record is written only after the
file response has been built, so a request that 404s never counts as a download. And the record is
deduplicated per (user, dataset, cookie): the route reuses the visitor’s download_cookie if one is
already present and only sets it when absent, so repeated downloads from the same browser count once.
The cookie carries a two-year lifetime, matching hubfile’s, so the dedupe survives browser restarts. The DOI landing
page applies the same idea to views through a view_cookie.
REST API
app/features/dataset/api.py registers a Flask-RESTful resource on the blueprint at
/api/v1/datasets/ and /api/v1/datasets/<int:id>. The resource is generated by
splent_framework’s create_resource(DataSet, dataset_serializer), a generic CRUD resource — that
is why route:list shows DELETE, GET, POST, PUT on both rules. In practice the read side is the
useful one; the serializer exposes dataset_id, created, name, doi and the nested files with
their human-readable size:
curl http://localhost/api/v1/datasets/
curl http://localhost/api/v1/datasets/1
{
"dataset_id": 1,
"created": "2026-07-21T12:32:06",
"name": "Sample dataset 1",
"doi": "http://localhost/doi/10.1234/dataset1",
"files": [
{"file_id": 1, "file_name": "file1.uvl", "size": "414 bytes"}
]
}
Models
app/features/dataset/models.py defines:
| Model | Table | Purpose |
|---|---|---|
DataSet |
data_set |
One dataset. FK user_id to auth’s user (not nullable), FK ds_meta_data_id. Has the feature_models relationship. |
DSMetaData |
ds_meta_data |
Title, description, publication type, tags, publication_doi, dataset_doi, Zenodo deposition_id. |
Author |
author |
An author row. Carries two FKs: ds_meta_data_id into this feature and fm_meta_data_id into featuremodel’s fm_meta_data table. |
DSMetrics |
ds_metrics |
Aggregate numbers attached to DSMetaData. |
DSDownloadRecord |
ds_download_record |
One download, with nullable user_id and the deduplication cookie. |
DSViewRecord |
ds_view_record |
One view, same shape. |
DOIMapping |
doi_mapping |
Maps a superseded DOI to its replacement for /doi/<doi>/ redirects. |
PublicationType is the enum behind the publication type of both datasets and feature models —
featuremodel imports it from here.
Two DataSet helpers are worth knowing. get_zenodo_url() returns the Zenodo record URL only when
the metadata has both a dataset_doi and a deposition_id, and None otherwise, so templates
and to_dict() can render unsynchronized datasets without a broken link. And to_dict() guards
tags before splitting, so a NULL tags column serializes as an empty list; the detail template
applies the same guard before iterating.
Services and repositories
app/features/dataset/services.py:
| Service | Purpose |
|---|---|
DataSetService |
The orchestrator: create_from_form (one transaction for metadata, authors, feature models and file rows), move_feature_models, save_temp_uvl, delete_temp_file, upload_dataset (local persist, then best-effort Zenodo), build_download_archive, get_uvlhub_doi, plus the synchronized/unsynchronized lookups and the statistics counts. |
DSDownloadRecordService |
record_download, deduplicated per (user, dataset, cookie). |
DSViewRecordService |
create_cookie, which records a view once per cookie. |
DSMetaDataService |
Metadata updates and filter_by_doi. |
DOIMappingService |
Resolves old DOIs to new ones. |
AuthorService |
Thin CRUD wrapper. |
SizeService |
get_human_readable_size, the bytes-to-KB/MB/GB formatter the whole app uses. |
build_download_archive resolves the dataset’s upload folder under WORKING_DIR
(<WORKING_DIR>/uploads/user_<id>/dataset_<id>) and writes the zip into a fresh temp directory, so
the archive path never depends on the process’ current working directory.
app/features/dataset/repositories.py holds one repository per model, all extending
BaseRepository. The statistics the public landing page shows (count_synchronized_datasets,
total_dataset_downloads, total_dataset_views) are computed in SQL with COUNT, not by loading
rows into Python and counting them.
Dependencies
Measured over production code only (tests excluded), dataset imports four other features:
| Imports | Where | Why |
|---|---|---|
auth.services |
services.py |
AuthenticationService resolves the current user’s temp folder in move_feature_models. |
featuremodel.repositories |
services.py |
DataSetService instantiates FeatureModelRepository and FMMetaDataRepository directly. This is a layer violation: a service reaching into another feature’s repositories, skipping FeatureModelService. |
hubfile.repositories |
services.py |
Same pattern with HubfileRepository and the record repositories — the same layer violation. |
zenodo.services |
services.py, lazily |
ZenodoService is imported inside upload_dataset, not at module level. Removing zenodo therefore does not break importing dataset; it breaks the upload at call time instead. |
The seeder (seeders.py) additionally imports auth.models, featuremodel.models and
hubfile.models to build the sample data.
dataset is also the most depended-on feature in the codebase. Six features import it: explore
(queries its models to search), featuremodel (imports Author and PublicationType at module
level in its models.py), hubfile (models, repositories and services), profile (uses
DataSetRepository for the user’s dataset list), public (landing-page statistics via
DataSetService) and zenodo (takes DataSet objects as input).
There is also coupling that no import graph shows: the templates. view_dataset.html builds URLs
into two other blueprints with url_for — four references into flamapy
(flamapy.valid, flamapy.to_glencoe, flamapy.to_cnf, flamapy.to_splot) and one into hubfile
(hubfile.download_file):
<a class="dropdown-item" href="{{ url_for('flamapy.valid', file_id=file.id) }}">SAT validity check</a>
<a class="dropdown-item" href="{{ url_for('hubfile.download_file', file_id=file.id) }}">
Disabling flamapy in [tool.splent] breaks the dataset detail page. With the flamapy
blueprint unregistered, the first url_for('flamapy...') in view_dataset.html raises a
BuildError and the DOI landing page answers 500. The [tool.splent] feature lists make flamapy
look optional; this template dependency means that, for dataset, it is not.
The template’s JavaScript also fetches /file/view/<id> and /file/download/<id> (hubfile routes)
by literal path.
Taken together: dataset, featuremodel and
hubfile form a module-level dependency triangle — each of the
three imports the other two. Together with auth they are the product’s core, and they are only
meaningfully deployable as a unit.
Templates and assets
app/features/dataset/
├── templates/dataset/
│ ├── list_datasets.html # /dataset/list
│ ├── upload_dataset.html # /dataset/upload
│ └── view_dataset.html # /doi/<doi>/ and /dataset/unsynchronized/<id>/
├── assets/js/scripts.js # upload form: author blocks, staging uploads, submit
└── uvl_examples/ # file1.uvl … file12.uvl, copied by the seeder
scripts.js is registered through the framework asset registry in init_feature and drives the
upload page: dynamic author sub-forms, staging files to /dataset/file/upload, and submitting the
whole form to /dataset/upload. view_dataset.html additionally loads Pyodide to run flamapy
checks client-side.
The seeder (DataSetSeeder, priority 2, after auth’s users) creates four sample datasets with
twelve feature models and copies the uvl_examples files into uploads/.
Tests
One file per layer, marker set at module level:
app/features/dataset/tests/
├── test_unit.py # pytest.mark.unit
├── test_repository.py # pytest.mark.repository
├── test_service.py # pytest.mark.service
├── test_integration.py # pytest.mark.integration
├── test_selenium.py # pytest.mark.e2e
└── locustfile.py # load tests
The suite pins the behaviors described above: get_zenodo_url returning None without a
deposition, the DOI landing page rendering a dataset without tags, temp-file upload/delete and the
login redirect on /dataset/file/delete, build_download_archive zipping the upload folder, and
download records deduplicated per cookie. Run one layer at a time:
rosemary test dataset --unit
rosemary test dataset --integration
rosemary test dataset --e2e
Configuration
grep os.getenv over the feature’s production code finds two variables:
| Variable | Read in | Used for |
|---|---|---|
WORKING_DIR |
services.py (twice), seeders.py |
Root under which uploads/user_<id>/dataset_<id>/ lives; also where the seeder finds uvl_examples. |
DOMAIN |
services.py |
Builds the public DOI URL, http://<DOMAIN>/doi/<doi> (default localhost). |
The feature ships no .env.example of its own; both variables come from the root .env. The Zenodo
credentials used during upload belong to the zenodo feature.