Repository: CTFd/CTFd

Issue:
get_standings sorted by sumscore->max(solves.id,awards.id)
Right now the standings are sorted by (sumscore,id). 
As can found in `CTFd/utils/scores/__init__.py:get_standings`
```python
standings_query = db.session.query(
            Model.id.label('account_id'),
            Model.oauth_id.label('oauth_id'),
            Model.name.label('name'),
            sumscores.columns.score
        ) \
            .join(sumscores, Model.id == sumscores.columns.account_id) \
            .filter(Model.banned == False, Model.hidden == False) \
.order_by(sumscores.columns.score.desc(), sumscores.columns.id)
#(the sumscores.columns.id is a union of solves.id and awards.id)
```
When two users get the same sumscore,  their standings depend on max(max(Solves.id),max(Awards.id)).

I think it would be better to sorted by (sumscore,date) because solves.id and awards.id are not the same thing, if more than one user gets the same score with both solves and awards, who reaches the score first should rank ahead.



Discussion:
I'm not entirely sure what you're referring to. Can you provide a test that CTFd would fail? 

If two teams reach the same score, the person who reaches the score first is considered the person on top. An award should not change that logic. 
For example,

| time | user | type | scores | id |
| ------ | ------ | ------ | ----- | ----- |
| 1 | user1 | solve | 100 | solveid:1 |
| 2 | user2 | solve | 200 | solveid:2 |
| 3 | user1 | award | 100 | awardid:1| 

User 2 reaches 200 points first, but user1 is on top becase user1 has less max(solveid,awardid)
Here is the screenshot:

![screenshot from 2018-11-15 14-49-51](https://user-images.githubusercontent.com/31540650/51305851-ee6b8e00-1a76-11e9-8727-320bdd8c009e.jpeg)

@ColdHeat 


Okay I see your point. Would you be willing to PR the fix for this?

On Thu, Jan 17, 2019 at 3:44 AM XuHaoyu <notifications@github.com> wrote:

> For example,
> time user type scores id
> 1 user1 solve 100 solveid:1
> 2 user2 solve 200 solveid:2
> 3 user1 award 100 awardid:1
>
> User 2 reaches 200 points first, but user1 is on top becase user1 has less
> max(solveid,awardid)
> Here is the screenshot:
>
> [image: screenshot from 2018-11-15 14-49-51]
> <https://user-images.githubusercontent.com/31540650/51305851-ee6b8e00-1a76-11e9-8727-320bdd8c009e.jpeg>
>
> —
> You are receiving this because you commented.
>
>
> Reply to this email directly, view it on GitHub
> <https://github.com/CTFd/CTFd/issues/833#issuecomment-455089172>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AAKJvQJUm_y2V-frQm4A9yirN1EVQTCwks5vEDgGgaJpZM4aArlH>
> .
>


Resolving patch:
diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py
--- a/CTFd/models/__init__.py
+++ b/CTFd/models/__init__.py
@@ -3,6 +3,7 @@
 
 from flask_marshmallow import Marshmallow
 from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy.ext.compiler import compiles
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import column_property, validates
 
@@ -25,6 +26,16 @@ def get_class_by_tablename(tablename):
     return None
 
 
+@compiles(db.DateTime, "mysql")
+def compile_datetime_mysql(_type, _compiler, **kw):
+    """
+    This decorator makes the default db.DateTime class always enable fsp to enable millisecond precision
+    https://dev.mysql.com/doc/refman/5.7/en/fractional-seconds.html
+    https://docs.sqlalchemy.org/en/14/core/custom_types.html#overriding-type-compilation
+    """
+    return "DATETIME(6)"
+
+
 class Notifications(db.Model):
     __tablename__ = "notifications"
     id = db.Column(db.Integer, primary_key=True)
diff --git a/CTFd/utils/scores/__init__.py b/CTFd/utils/scores/__init__.py
--- a/CTFd/utils/scores/__init__.py
+++ b/CTFd/utils/scores/__init__.py
@@ -91,7 +91,11 @@ def get_standings(count=None, admin=False, fields=None):
                 *fields,
             )
             .join(sumscores, Model.id == sumscores.columns.account_id)
-            .order_by(sumscores.columns.score.desc(), sumscores.columns.id)
+            .order_by(
+                sumscores.columns.score.desc(),
+                sumscores.columns.date.asc(),
+                sumscores.columns.id.asc(),
+            )
         )
     else:
         standings_query = (
@@ -104,7 +108,11 @@ def get_standings(count=None, admin=False, fields=None):
             )
             .join(sumscores, Model.id == sumscores.columns.account_id)
             .filter(Model.banned == False, Model.hidden == False)
-            .order_by(sumscores.columns.score.desc(), sumscores.columns.id)
+            .order_by(
+                sumscores.columns.score.desc(),
+                sumscores.columns.date.asc(),
+                sumscores.columns.id.asc(),
+            )
         )
 
     """
@@ -175,7 +183,11 @@ def get_team_standings(count=None, admin=False, fields=None):
                 *fields,
             )
             .join(sumscores, Teams.id == sumscores.columns.team_id)
-            .order_by(sumscores.columns.score.desc(), sumscores.columns.id)
+            .order_by(
+                sumscores.columns.score.desc(),
+                sumscores.columns.date.asc(),
+                sumscores.columns.id.asc(),
+            )
         )
     else:
         standings_query = (
@@ -189,7 +201,11 @@ def get_team_standings(count=None, admin=False, fields=None):
             .join(sumscores, Teams.id == sumscores.columns.team_id)
             .filter(Teams.banned == False)
             .filter(Teams.hidden == False)
-            .order_by(sumscores.columns.score.desc(), sumscores.columns.id)
+            .order_by(
+                sumscores.columns.score.desc(),
+                sumscores.columns.date.asc(),
+                sumscores.columns.id.asc(),
+            )
         )
 
     if count is None:
@@ -258,7 +274,11 @@ def get_user_standings(count=None, admin=False, fields=None):
                 *fields,
             )
             .join(sumscores, Users.id == sumscores.columns.user_id)
-            .order_by(sumscores.columns.score.desc(), sumscores.columns.id)
+            .order_by(
+                sumscores.columns.score.desc(),
+                sumscores.columns.date.asc(),
+                sumscores.columns.id.asc(),
+            )
         )
     else:
         standings_query = (
@@ -272,7 +292,11 @@ def get_user_standings(count=None, admin=False, fields=None):
             )
             .join(sumscores, Users.id == sumscores.columns.user_id)
             .filter(Users.banned == False, Users.hidden == False)
-            .order_by(sumscores.columns.score.desc(), sumscores.columns.id)
+            .order_by(
+                sumscores.columns.score.desc(),
+                sumscores.columns.date.asc(),
+                sumscores.columns.id.asc(),
+            )
         )
 
     if count is None:
diff --git a/migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py b/migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py
new file mode 100644
--- /dev/null
+++ b/migrations/versions/46a278193a94_enable_millisecond_precision_in_mysql_.py
@@ -0,0 +1,46 @@
+"""Enable millisecond precision in MySQL datetime
+
+Revision ID: 46a278193a94
+Revises: 4d3c1b59d011
+Create Date: 2022-11-01 23:27:44.620893
+
+"""
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+
+# revision identifiers, used by Alembic.
+revision = "46a278193a94"
+down_revision = "4d3c1b59d011"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    bind = op.get_bind()
+    url = str(bind.engine.url)
+    if url.startswith("mysql"):
+        get_columns = "SELECT `TABLE_NAME`, `COLUMN_NAME` FROM `information_schema`.`COLUMNS` WHERE `table_schema`=DATABASE() AND `DATA_TYPE`='datetime' AND `COLUMN_TYPE`='datetime';"
+        conn = op.get_bind()
+        columns = conn.execute(get_columns).fetchall()
+        for table_name, column_name in columns:
+            op.alter_column(
+                table_name=table_name,
+                column_name=column_name,
+                type_=mysql.DATETIME(fsp=6),
+            )
+
+
+def downgrade():
+    bind = op.get_bind()
+    url = str(bind.engine.url)
+    if url.startswith("mysql"):
+        get_columns = "SELECT `TABLE_NAME`, `COLUMN_NAME` FROM `information_schema`.`COLUMNS` WHERE `table_schema`=DATABASE() AND `DATA_TYPE`='datetime' AND `COLUMN_TYPE`='datetime(6)';"
+        conn = op.get_bind()
+        columns = conn.execute(get_columns).fetchall()
+        for table_name, column_name in columns:
+            op.alter_column(
+                table_name=table_name,
+                column_name=column_name,
+                type_=mysql.DATETIME(fsp=0),
+            )


Test patch:
diff --git a/tests/api/v1/test_scoreboard.py b/tests/api/v1/test_scoreboard.py
--- a/tests/api/v1/test_scoreboard.py
+++ b/tests/api/v1/test_scoreboard.py
@@ -4,12 +4,15 @@
 from flask_caching import make_template_fragment_key
 
 from CTFd.cache import clear_standings
+from CTFd.models import Users
 from tests.helpers import (
     create_ctfd,
     destroy_ctfd,
+    gen_award,
     gen_challenge,
     gen_flag,
     gen_solve,
+    gen_team,
     login_as_user,
     register_user,
 )
@@ -58,3 +61,95 @@ def test_scoreboard_is_cached():
                 is None
             )
     destroy_ctfd(app)
+
+
+def test_scoreboard_tie_break_ordering_with_awards():
+    """
+    Test that scoreboard tie break ordering respects the addition of awards
+    """
+    app = create_ctfd()
+    with app.app_context():
+        # create user1
+        register_user(app, name="user1", email="user1@examplectf.com")
+        # create user2
+        register_user(app, name="user2", email="user2@examplectf.com")
+
+        chal = gen_challenge(app.db, value=100)
+        gen_flag(app.db, challenge_id=chal.id, content="flag")
+
+        chal = gen_challenge(app.db, value=200)
+        gen_flag(app.db, challenge_id=chal.id, content="flag")
+
+        # create solves for the challenges. (the user_ids are off by 1 because of the admin)
+        gen_solve(app.db, user_id=2, challenge_id=1)
+        gen_solve(app.db, user_id=3, challenge_id=2)
+
+        with login_as_user(app, "user1") as client:
+            r = client.get("/api/v1/scoreboard")
+            resp = r.get_json()
+            assert len(resp["data"]) == 2
+            assert resp["data"][0]["name"] == "user2"
+            assert resp["data"][0]["score"] == 200
+            assert resp["data"][1]["name"] == "user1"
+            assert resp["data"][1]["score"] == 100
+
+        # Give user1 an award for 100 points.
+        # At this point user2 should still be ahead
+        gen_award(app.db, user_id=2, value=100)
+
+        with login_as_user(app, "user1") as client:
+            r = client.get("/api/v1/scoreboard")
+            resp = r.get_json()
+            assert len(resp["data"]) == 2
+            assert resp["data"][0]["name"] == "user2"
+            assert resp["data"][0]["score"] == 200
+            assert resp["data"][1]["name"] == "user1"
+            assert resp["data"][1]["score"] == 200
+    destroy_ctfd(app)
+
+
+def test_scoreboard_tie_break_ordering_with_awards_under_teams():
+    """
+    Test that team mode scoreboard tie break ordering respects the addition of awards
+    """
+    app = create_ctfd(user_mode="teams")
+    with app.app_context():
+        gen_team(app.db, name="team1", email="team1@examplectf.com")
+        gen_team(app.db, name="team2", email="team2@examplectf.com")
+
+        chal = gen_challenge(app.db, value=100)
+        gen_flag(app.db, challenge_id=chal.id, content="flag")
+
+        chal = gen_challenge(app.db, value=200)
+        gen_flag(app.db, challenge_id=chal.id, content="flag")
+
+        # create solves for the challenges. (the user_ids are off by 1 because of the admin)
+        gen_solve(app.db, user_id=2, team_id=1, challenge_id=1)
+        gen_solve(app.db, user_id=6, team_id=2, challenge_id=2)
+
+        user = Users.query.filter_by(id=2).first()
+
+        with login_as_user(app, user.name) as client:
+            r = client.get("/api/v1/scoreboard")
+            resp = r.get_json()
+            print(resp)
+            assert len(resp["data"]) == 2
+            assert resp["data"][0]["name"] == "team2"
+            assert resp["data"][0]["score"] == 200
+            assert resp["data"][1]["name"] == "team1"
+            assert resp["data"][1]["score"] == 100
+
+        # Give a user on the team an award for 100 points.
+        # At this point team2 should still be ahead
+        gen_award(app.db, user_id=3, team_id=1, value=100)
+
+        with login_as_user(app, user.name) as client:
+            r = client.get("/api/v1/scoreboard")
+            resp = r.get_json()
+            print(resp)
+            assert len(resp["data"]) == 2
+            assert resp["data"][0]["name"] == "team2"
+            assert resp["data"][0]["score"] == 200
+            assert resp["data"][1]["name"] == "team1"
+            assert resp["data"][1]["score"] == 200
+    destroy_ctfd(app)