mpacer: Also, my tests pass on both py2 and py3, not sure why they would be failing on Appveyor. The errors also seem unreleated to what I touched.
mpacer: So much code sharing led me to think this should be genericised.

The method for defining the gradebook update method seemed a little hacky, but I couldn't think of any other way to do pass through the gradebook instance from within the context manager.
jhamrick: Unfortunately it does look like replacing the traitlets with properties causes issues when building the docs 😞 
mpacer: I can use traitlet's dynamic defaults instead :).
mpacer: I actually don't think that name was ever a traitlet, or we wouldn't be able to run into this problem.
mpacer: also… looking at the doc generation code, it needs to be a class attribute, not an instance attribute, so traitlets couldn't work.

```python
        name = cls.name.replace(" ", "-")
```
diff --git a/nbgrader/apps/dbapp.py b/nbgrader/apps/dbapp.py
index 8bab3443..ee9a543c 100644
--- a/nbgrader/apps/dbapp.py
+++ b/nbgrader/apps/dbapp.py
@@ -6,11 +6,11 @@ import os
 import shutil
 
 from textwrap import dedent
-from traitlets import default, Unicode, Bool
+from traitlets import default, Unicode, Bool, List
 from datetime import datetime
 
 from . import NbGrader
-from ..api import Gradebook, MissingEntry
+from ..api import Gradebook, MissingEntry, Student, Assignment
 from .. import dbutil
 
 aliases = {
@@ -116,16 +116,43 @@ class DbStudentRemoveApp(NbGrader):
             gb.remove_student(student_id)
 
 
-class DbStudentImportApp(NbGrader):
-
-    name = 'nbgrader-db-student-import'
-    description = 'Import students into the nbgrader database from a CSV file'
+class DbGenericImportApp(NbGrader):
 
     aliases = aliases
     flags = flags
 
+    expected_keys = List(help="These are the keys expected by the database")
+
+    def db_update_method_name(self):
+        """
+        Name of the update method used on the Gradebook for this import app.
+        It is expected to have the signature:
+            * instance_id : string, identifies which instance you are updating based on self.primary_key
+            * instance : dictionary, contents for the update from the parsed csv rows; unpacked as kwargs
+        """
+        raise NotImplementedError
+
+    name = ""
+    description = ""
+
+
+    @property
+    def table_class(self):
+        raise NotImplementedError
+
+    @property
+    def primary_key_default(self):
+        """
+        The key for the instance_id passed to the get_db_update_method.
+        """
+        raise NotImplementedError
+
+    @default("expected_keys")
+    def expected_keys_default(self):
+        return self.table_class.__table__.c.keys()
+
     def start(self):
-        super(DbStudentImportApp, self).start()
+        super(DbGenericImportApp, self).start()
 
         if len(self.extra_args) != 1:
             self.fail("Path to CSV file not provided.")
@@ -133,31 +160,69 @@ class DbStudentImportApp(NbGrader):
         path = self.extra_args[0]
         if not os.path.exists(path):
             self.fail("No such file: '%s'", path)
-        self.log.info("Importing students from: '%s'", path)
+        self.log.info("Importing from: '%s'", path)
 
-        allowed_keys = ["last_name", "first_name", "email", "id"]
 
         with Gradebook(self.coursedir.db_url) as gb:
             with open(path, 'r') as fh:
                 reader = csv.DictReader(fh)
+                reader.fieldnames = self._preprocess_keys(reader.fieldnames)
                 for row in reader:
-                    if "id" not in row:
-                        self.fail("Malformatted CSV file: must contain a column for 'id'")
+                    if self.primary_key not in row:
+                        self.fail("Malformatted CSV file: must contain a column for '%s'" % self.primary_key)
 
                     # make sure all the keys are actually allowed in the database,
                     # and that any empty strings are parsed as None
-                    student = {}
+                    instance = {}
                     for key, val in row.items():
-                        if key not in allowed_keys:
+                        if key not in self.expected_keys:
                             continue
                         if val == '':
-                            student[key] = None
+                            instance[key] = None
                         else:
-                            student[key] = val
-                    student_id = student.pop("id")
+                            instance[key] = val
+                    instance_primary_key = instance.pop(self.primary_key)
+
+
+                    self.log.info("Creating/updating %s with %s '%s': %s",
+                                  self.table_class.__name__,
+                                  self.primary_key,
+                                  instance_primary_key,
+                                  instance)
+                    db_update_method = getattr(gb, self.db_update_method_name)
+                    db_update_method(instance_primary_key, **instance)
 
-                    self.log.info("Creating/updating student with ID '%s': %s", student_id, student)
-                    gb.update_or_create_student(student_id, **student)
+
+    def _preprocess_keys(self, keys):
+        """
+        Helper function for preprocessing keys
+        """
+        proposed_keys = [key.strip() for key in keys]
+        unknown_keys = [k for k in proposed_keys if k not in self.expected_keys]
+        if unknown_keys:
+            self.log.info("Unknown keys in csv: '%s'",
+                          (', '.join(unknown_keys[:-1])
+                           + 'and '
+                           + unknown_keys[-1]))
+        return proposed_keys
+
+
+class DbStudentImportApp(DbGenericImportApp):
+
+    name = 'nbgrader-db-student-import'
+    description = 'Import students into the nbgrader database from a CSV file'
+
+    @property
+    def table_class(self):
+        return Student
+
+    @property
+    def primary_key(self):
+        return "id"
+
+    @property
+    def db_update_method_name(self):
+        return "update_or_create_student"
 
 
 class DbStudentListApp(NbGrader):
@@ -258,49 +323,22 @@ class DbAssignmentRemoveApp(NbGrader):
             gb.remove_assignment(assignment_id)
 
 
-class DbAssignmentImportApp(NbGrader):
+class DbAssignmentImportApp(DbGenericImportApp):
 
     name = 'nbgrader-db-assignment-import'
     description = 'Import assignments into the nbgrader database from a CSV file'
 
-    aliases = aliases
-    flags = flags
-
-    def start(self):
-        super(DbAssignmentImportApp, self).start()
-
-        if len(self.extra_args) != 1:
-            self.fail("Path to CSV file not provided.")
-
-        path = self.extra_args[0]
-        if not os.path.exists(path):
-            self.fail("No such file: '%s'", path)
-        self.log.info("Importing assignments from: '%s'", path)
-
-        allowed_keys = ["duedate", "name"]
-
-        with Gradebook(self.coursedir.db_url) as gb:
-            with open(path, 'r') as fh:
-                reader = csv.DictReader(fh)
-                for row in reader:
-                    if "name" not in row:
-                        self.fail("Malformatted CSV file: must contain a column for 'name'")
-
-                    # make sure all the keys are actually allowed in the database,
-                    # and that any empty strings are parsed as None
-                    assignment = {}
-                    for key, val in row.items():
-                        if key not in allowed_keys:
-                            continue
-                        if val == '':
-                            assignment[key] = None
-                        else:
-                            assignment[key] = val
-                    assignment_id = assignment.pop("name")
+    @property
+    def table_class(self):
+        return Assignment
 
-                    self.log.info("Creating/updating assignment with name '%s': %s", assignment_id, assignment)
-                    gb.update_or_create_assignment(assignment_id, **assignment)
+    @property
+    def primary_key(self):
+        return "name"
 
+    @property
+    def db_update_method_name(self):
+        return "update_or_create_assignment"
 
 class DbAssignmentListApp(NbGrader):
 

Not importing emails when Importing students from .csv 
### Operating system
`Ubuntu 16.04`

### `nbgrader --version`

`nbgrader version 0.5.2`

### Expected behavior

I'm creating the students database from a `csv` file.  My csv files has:
`id,last_name,first_name,email` so I expect when I do `nbgrader db student list` to have all the fields
for each student.

### Actual behavior

For some reason the field corresponding to email it's not being import. For example in the csv file I have:
`id,last_name,first_name,email`
`stud1, smith, blabla, bla@mail.edu`

and when creating the database I got:

`Creating/updating student with ID 'stud1': {'last_name': 'smith', 'first_name': 'blabla'}`

when listing students I see: 

`stud1 (smith, blabla) -- None`

Any clue what is going on? 

diff --git a/nbgrader/tests/apps/test_nbgrader_db.py b/nbgrader/tests/apps/test_nbgrader_db.py
index fd0803cb..5b7789da 100644
--- a/nbgrader/tests/apps/test_nbgrader_db.py
+++ b/nbgrader/tests/apps/test_nbgrader_db.py
@@ -176,6 +176,28 @@ class TestNbGraderDb(BaseTestApp):
             assert student.first_name is None
             assert student.email is None
 
+
+    def test_student_import_csv_spaces(self, db, temp_cwd):
+        with open("students.csv", "w") as fh:
+            fh.write(dedent(
+                """
+                id,first_name,last_name, email
+                foo,abc,xyz,foo@bar.com
+                bar,,,
+                """
+            ).strip())
+
+        run_nbgrader(["db", "student", "import", "students.csv", "--db", db])
+        with Gradebook(db) as gb:
+            student = gb.find_student("foo")
+            assert student.last_name == "xyz"
+            assert student.first_name == "abc"
+            assert student.email == "foo@bar.com"
+            student = gb.find_student("bar")
+            assert student.last_name is None
+            assert student.first_name is None
+            assert student.email is None
+
     def test_assignment_add(self, db):
         run_nbgrader(["db", "assignment", "add", "foo", "--db", db])
         with Gradebook(db) as gb:
@@ -256,6 +278,24 @@ class TestNbGraderDb(BaseTestApp):
             assignment = gb.find_assignment("bar")
             assert assignment.duedate is None
 
+
+    def test_assignment_import_csv_spaces(self, db, temp_cwd):
+        with open("assignments.csv", "w") as fh:
+            fh.write(dedent(
+                """
+                name, duedate
+                foo,Sun Jan 8 2017 4:31:22 PM
+                bar,
+                """
+            ).strip())
+
+        run_nbgrader(["db", "assignment", "import", "assignments.csv", "--db", db])
+        with Gradebook(db) as gb:
+            assignment = gb.find_assignment("foo")
+            assert assignment.duedate == datetime.datetime(2017, 1, 8, 16, 31, 22)
+            assignment = gb.find_assignment("bar")
+            assert assignment.duedate is None
+
         # check that it fails when no id column is given
         with open("assignments.csv", "w") as fh:
             fh.write(dedent(
@@ -284,7 +324,6 @@ class TestNbGraderDb(BaseTestApp):
             assert assignment.duedate == datetime.datetime(2017, 1, 8, 16, 31, 22)
             assignment = gb.find_assignment("bar")
             assert assignment.duedate is None
-
     def test_upgrade_nodb(self, temp_cwd):
         # test upgrading without a database
         run_nbgrader(["db", "upgrade"])

pip install -r dev-requirements.txt -e .
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-rerunfailures coverage selenium invoke sphinx codecov cov-core nbval
pip install -U pip wheel setuptools
3.5
dev-requirements.txt dev-requirements-windows.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
alembic==1.7.7
anyio==3.6.2
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
async-generator==1.10
attrs==22.2.0
Babel==2.11.0
backcall==0.2.0
bleach==4.1.0
certifi==2021.5.30
cffi==1.15.1
charset-normalizer==2.0.12
codecov==2.1.13
comm==0.1.4
contextvars==2.4
cov-core==1.15.0
coverage==6.2
dataclasses==0.8
decorator==5.1.1
defusedxml==0.7.1
docutils==0.18.1
entrypoints==0.4
greenlet==2.0.2
idna==3.10
imagesize==1.4.1
immutables==0.19
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
invoke==2.2.0
ipykernel==5.5.6
ipython==7.16.3
ipython-genutils==0.2.0
ipywidgets==7.8.5
jedi==0.17.2
Jinja2==3.0.3
json5==0.9.16
jsonschema==3.2.0
jupyter==1.1.1
jupyter-client==7.1.2
jupyter-console==6.4.3
jupyter-core==4.9.2
jupyter-server==1.13.1
jupyterlab==3.2.9
jupyterlab-pygments==0.1.2
jupyterlab-server==2.10.3
jupyterlab_widgets==1.1.11
Mako==1.1.6
MarkupSafe==2.0.1
mistune==0.8.4
nbclassic==0.3.5
nbclient==0.5.9
nbconvert==6.0.7
nbformat==5.1.3
-e git+https://github.com/jupyter/nbgrader.git@fc13b045cc085bb2a5355131b8e0f20dd7607884#egg=nbgrader
nbval==0.10.0
nest-asyncio==1.6.0
notebook==6.4.10
packaging==21.3
pandocfilters==1.5.1
parso==0.7.1
pexpect==4.9.0
pickleshare==0.7.5
pluggy==1.0.0
prometheus-client==0.17.1
prompt-toolkit==3.0.36
ptyprocess==0.7.0
py==1.11.0
pycparser==2.21
pyenchant==3.2.2
Pygments==2.14.0
pyparsing==3.1.4
pyrsistent==0.18.0
pytest==7.0.1
pytest-cov==4.0.0
pytest-rerunfailures==10.3
python-dateutil==2.9.0.post0
pytz==2025.2
pyzmq==25.1.2
requests==2.27.1
selenium==3.141.0
Send2Trash==1.8.3
six==1.17.0
sniffio==1.2.0
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
sphinxcontrib-spelling==7.7.0
SQLAlchemy==1.4.54
terminado==0.12.1
testpath==0.6.0
tomli==1.2.3
tornado==6.1
traitlets==4.3.3
typing_extensions==4.1.1
urllib3==1.26.20
wcwidth==0.2.13
webencodings==0.5.1
websocket-client==1.3.1
widgetsnbextension==3.6.10
zipp==3.6.0

name: nbgrader
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - alembic==1.7.7
      - anyio==3.6.2
      - argon2-cffi==21.3.0
      - argon2-cffi-bindings==21.2.0
      - async-generator==1.10
      - attrs==22.2.0
      - babel==2.11.0
      - backcall==0.2.0
      - bleach==4.1.0
      - cffi==1.15.1
      - charset-normalizer==2.0.12
      - codecov==2.1.13
      - comm==0.1.4
      - contextvars==2.4
      - cov-core==1.15.0
      - coverage==6.2
      - dataclasses==0.8
      - decorator==5.1.1
      - defusedxml==0.7.1
      - docutils==0.18.1
      - entrypoints==0.4
      - greenlet==2.0.2
      - idna==3.10
      - imagesize==1.4.1
      - immutables==0.19
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - invoke==2.2.0
      - ipykernel==5.5.6
      - ipython==7.16.3
      - ipython-genutils==0.2.0
      - ipywidgets==7.8.5
      - jedi==0.17.2
      - jinja2==3.0.3
      - json5==0.9.16
      - jsonschema==3.2.0
      - jupyter==1.1.1
      - jupyter-client==7.1.2
      - jupyter-console==6.4.3
      - jupyter-core==4.9.2
      - jupyter-server==1.13.1
      - jupyterlab==3.2.9
      - jupyterlab-pygments==0.1.2
      - jupyterlab-server==2.10.3
      - jupyterlab-widgets==1.1.11
      - mako==1.1.6
      - markupsafe==2.0.1
      - mistune==0.8.4
      - nbclassic==0.3.5
      - nbclient==0.5.9
      - nbconvert==6.0.7
      - nbformat==5.1.3
      - nbval==0.10.0
      - nest-asyncio==1.6.0
      - notebook==6.4.10
      - packaging==21.3
      - pandocfilters==1.5.1
      - parso==0.7.1
      - pexpect==4.9.0
      - pickleshare==0.7.5
      - pip==21.3.1
      - pluggy==1.0.0
      - prometheus-client==0.17.1
      - prompt-toolkit==3.0.36
      - ptyprocess==0.7.0
      - py==1.11.0
      - pycparser==2.21
      - pyenchant==3.2.2
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pyrsistent==0.18.0
      - pytest==7.0.1
      - pytest-cov==4.0.0
      - pytest-rerunfailures==10.3
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyzmq==25.1.2
      - requests==2.27.1
      - selenium==3.141.0
      - send2trash==1.8.3
      - setuptools==59.6.0
      - six==1.17.0
      - sniffio==1.2.0
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - sphinxcontrib-spelling==7.7.0
      - sqlalchemy==1.4.54
      - terminado==0.12.1
      - testpath==0.6.0
      - tomli==1.2.3
      - tornado==6.1
      - traitlets==4.3.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - wcwidth==0.2.13
      - webencodings==0.5.1
      - websocket-client==1.3.1
      - widgetsnbextension==3.6.10
      - zipp==3.6.0
prefix: /opt/conda/envs/nbgrader

nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_student_import_csv_spaces nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_assignment_import_csv_spaces
nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_help nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_no_args nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_student_add nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_student_remove nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_student_remove_with_submissions nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_student_list nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_student_import nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_assignment_add nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_assignment_remove nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_assignment_remove_with_submissions nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_assignment_list nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_assignment_import nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_upgrade_nodb nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_upgrade_current_db nbgrader/tests/apps/test_nbgrader_db.py::TestNbGraderDb::test_upgrade_old_db
nbgrader/apps/dbapp.py
nbgrader/apps/dbapp.py