Repository: CTFd/CTFd

Issue:
Two identical "id" columns in exported "solves" CSV file
**Environment**:
- CTFd Version: 3.6.0
- This issue **was not** present in CTFd 3.5.3

**What happened?**
When exporting the "solves" CSV, the resulting CSV file contains two "id" columns that are identical.
This is the first line (header) in the CSV file: `challenge_id,user_id,team_id,id,ip,provided,type,date,id`

**What did you expect to happen?**
The column "id" should be exported to the CSV file only once. Having two columns with the same name creates various issues when processing these CSV files with third-party tools.

**How to reproduce your issue**
1. Log in to the CTFd Server as an administrator and go to **Config** > **Backup** > **Download CSV**.
2. Select "solves" from the Database Table drop-down menu and click the **Download CSV** button.
3. Inspect the downloaded CSV file.


Discussion:
I can reproduce this. I do not know the cause. It's likely to do with the SQLAlchemy version bump. I will investigate when I can but a PR would also be helpful. 

Resolving patch:
diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py
--- a/CTFd/models/__init__.py
+++ b/CTFd/models/__init__.py
@@ -20,11 +20,26 @@ def get_class_by_tablename(tablename):
     :param tablename: String with name of table.
     :return: Class reference or None.
     """
+    classes = []
     for m in db.Model.registry.mappers:
         c = m.class_
         if hasattr(c, "__tablename__") and c.__tablename__ == tablename:
-            return c
-    return None
+            classes.append(c)
+
+    # We didn't find this class
+    if len(classes) == 0:
+        return None
+    # This is a class where we have only one possible candidate.
+    # It's either a top level class or a polymorphic class with a specific hardcoded table name
+    elif len(classes) == 1:
+        return classes[0]
+    # In this case we are dealing with a polymorphic table where all of the tables have the same table name.
+    # However for us to identify the parent class we can look for the class that defines the polymorphic_on arg
+    else:
+        for c in classes:
+            mapper_args = dict(c.__mapper_args__)
+            if mapper_args.get("polymorphic_on") is not None:
+                return c
 
 
 @compiles(db.DateTime, "mysql")
diff --git a/CTFd/utils/csv/__init__.py b/CTFd/utils/csv/__init__.py
--- a/CTFd/utils/csv/__init__.py
+++ b/CTFd/utils/csv/__init__.py
@@ -286,15 +286,13 @@ def dump_database_table(tablename):
     temp = StringIO()
     writer = csv.writer(temp)
 
-    header = [column.name for column in model.__mapper__.columns]
+    header = model.__mapper__.column_attrs.keys()
     writer.writerow(header)
 
     responses = model.query.all()
 
     for curr in responses:
-        writer.writerow(
-            [getattr(curr, column.name) for column in model.__mapper__.columns]
-        )
+        writer.writerow([getattr(curr, column) for column in header])
 
     temp.seek(0)
 


Test patch:
diff --git a/tests/models/test_model_utils.py b/tests/models/test_model_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/models/test_model_utils.py
@@ -0,0 +1,23 @@
+from CTFd.models import (
+    Challenges,
+    Comments,
+    Files,
+    Solves,
+    Submissions,
+    get_class_by_tablename,
+)
+from tests.helpers import create_ctfd, destroy_ctfd
+
+
+def test_get_class_by_tablename():
+    """
+    Test that get_class_by_tablename() returns the correct table
+    """
+    app = create_ctfd()
+    with app.app_context():
+        assert get_class_by_tablename("solves") == Solves
+        assert get_class_by_tablename("comments") == Comments
+        assert get_class_by_tablename("files") == Files
+        assert get_class_by_tablename("submissions") == Submissions
+        assert get_class_by_tablename("challenges") == Challenges
+    destroy_ctfd(app)